tailwind-ctp-intellisense/src/lsp/providers/diagnosticsProvider.ts

511 lines
15 KiB
TypeScript

import {
TextDocument,
Diagnostic,
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 { 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'
function getInvalidApplyDiagnostics(
state: State,
document: TextDocument,
settings: Settings
): Diagnostic[] {
let severity = settings.lint.invalidApply
if (severity === 'ignore') return []
const classNames = findClassNamesInRange(document, undefined, 'css')
let diagnostics: Diagnostic[] = classNames
.map(({ className, range }) => {
const meta = getClassNameMeta(state, className)
if (!meta) return null
let message: string
if (Array.isArray(meta)) {
message = `'@apply' cannot be used with '${className}' because it is included in multiple rulesets.`
} else if (meta.source !== 'utilities') {
message = `'@apply' cannot be used with '${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}' because it is nested inside of an at-rule ('${meta.context[0]}').`
} else {
message = `'@apply' cannot be used with '${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}' because its definition includes a pseudo-selector ('${meta.pseudo[0]}')`
} else {
message = `'@apply' cannot be used with '${className}' because its definition includes pseudo-selectors (${meta.pseudo
.map((p) => `'${p}'`)
.join(', ')}).`
}
}
if (!message) return null
return {
severity:
severity === 'error'
? DiagnosticSeverity.Error
: DiagnosticSeverity.Warning,
range,
message,
}
})
.filter(Boolean)
return diagnostics
}
function getUtilityConflictDiagnostics(
state: State,
document: TextDocument,
settings: Settings
): Diagnostic[] {
let severity = settings.lint.utilityConflicts
if (severity === 'ignore') return []
let diagnostics: Diagnostic[] = []
const classLists = findClassListsInDocument(state, document)
classLists.forEach((classList) => {
const classNames = getClassNamesInClassList(classList)
classNames.forEach((className, index) => {
let otherClassNames = classNames.filter((_className, i) => i !== index)
otherClassNames.forEach((otherClassName) => {
let decls = getClassNameDecls(state, className.className)
if (!decls) return
let otherDecls = getClassNameDecls(state, otherClassName.className)
if (!otherDecls) return
let meta = getClassNameMeta(state, className.className)
let otherMeta = getClassNameMeta(state, otherClassName.className)
if (
equal(Object.keys(decls), Object.keys(otherDecls)) &&
!Array.isArray(meta) &&
!Array.isArray(otherMeta) &&
equal(meta.context, otherMeta.context) &&
equal(meta.pseudo, otherMeta.pseudo)
) {
diagnostics.push({
range: className.range,
severity:
severity === 'error'
? DiagnosticSeverity.Error
: DiagnosticSeverity.Warning,
message: `'${className.className}' and '${otherClassName.className}' apply the same CSS properties.`,
relatedInformation: [
{
message: otherClassName.className,
location: {
uri: document.uri,
range: otherClassName.range,
},
},
],
})
}
})
})
})
return diagnostics
}
function getInvalidScreenDiagnostics(
state: State,
document: TextDocument,
settings: Settings
): Diagnostic[] {
let severity = settings.lint.invalidScreen
if (severity === 'ignore') return []
let diagnostics: Diagnostic[] = []
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+(?<screen>[^\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 suggestion = closest(match.groups.screen, screens)
if (suggestion) {
message += ` Did you mean '${suggestion}'?`
}
diagnostics.push({
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,
})
})
})
return diagnostics
}
function getInvalidVariantDiagnostics(
state: State,
document: TextDocument,
settings: Settings
): Diagnostic[] {
let severity = settings.lint.invalidVariant
if (severity === 'ignore') return []
let diagnostics: Diagnostic[] = []
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+(?<variants>[^{]+)/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 suggestion = closest(variant, state.variants)
if (suggestion) {
message += ` Did you mean '${suggestion}'?`
}
let variantStartIndex =
listStartIndex + variants.slice(0, i).join('').length
diagnostics.push({
range: absoluteRange(
{
start: indexToPosition(text, variantStartIndex),
end: indexToPosition(text, variantStartIndex + variant.length),
},
range
),
severity:
severity === 'error'
? DiagnosticSeverity.Error
: DiagnosticSeverity.Warning,
message,
})
}
})
})
return diagnostics
}
function getInvalidConfigPathDiagnostics(
state: State,
document: TextDocument,
settings: Settings
): Diagnostic[] {
let severity = settings.lint.invalidConfigPath
if (severity === 'ignore') return []
let diagnostics: Diagnostic[] = []
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(
/(?<prefix>\s|^)(?<helper>config|theme)\((?<quote>['"])(?<key>[^)]+)\k<quote>\)/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
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) {
message += ` Did you mean '${stitch([
...keys.slice(0, keys.length - 1),
closestValidKey,
])}'?`
}
}
} else {
message = `'${match.groups.key}' was found but does not resolve to a string.`
if (isObject(value)) {
let firstValidKey = Object.keys(value).find((key) =>
isValid(value[key])
)
if (firstValidKey) {
message += ` Did you mean something like '${stitch([
...keys,
firstValidKey,
])}'?`
}
}
}
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({
range: absoluteRange(
{
start: indexToPosition(text, startIndex),
end: indexToPosition(text, startIndex + match.groups.key.length),
},
range
),
severity:
severity === 'error'
? DiagnosticSeverity.Error
: DiagnosticSeverity.Warning,
message,
})
})
})
return diagnostics
}
function getInvalidTailwindDirectiveDiagnostics(
state: State,
document: TextDocument,
settings: Settings
): Diagnostic[] {
let severity = settings.lint.invalidTailwindDirective
if (severity === 'ignore') return []
let diagnostics: Diagnostic[] = []
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+(?<value>[^;]+)/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.`
if (match.groups.value === 'preflight') {
message += ` Did you mean 'base'?`
} else {
let suggestion = closest(match.groups.value, valid)
if (suggestion) {
message += ` Did you mean '${suggestion}'?`
}
}
diagnostics.push({
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,
})
})
})
return diagnostics
}
export async function provideDiagnostics(
state: State,
document: TextDocument
): Promise<void> {
const settings = await getDocumentSettings(state, document)
const diagnostics: Diagnostic[] = settings.validate
? [
...getUtilityConflictDiagnostics(state, document, settings),
...getInvalidApplyDiagnostics(state, document, settings),
...getInvalidScreenDiagnostics(state, document, settings),
...getInvalidVariantDiagnostics(state, document, settings),
...getInvalidConfigPathDiagnostics(state, document, settings),
...getInvalidTailwindDirectiveDiagnostics(state, document, settings),
]
: []
state.editor.connection.sendDiagnostics({
uri: document.uri,
diagnostics,
})
}
export function clearDiagnostics(state: State, document: TextDocument): void {
state.editor.connection.sendDiagnostics({
uri: document.uri,
diagnostics: [],
})
}
export function clearAllDiagnostics(state: State): void {
state.editor.documents.all().forEach((document) => {
clearDiagnostics(state, document)
})
}
export function updateAllDiagnostics(state: State): void {
state.editor.documents.all().forEach((document) => {
provideDiagnostics(state, document)
})
}