tailwind-ctp-intellisense/packages/tailwindcss-language-service/src/codeActions/provideInvalidApplyCodeActi...

250 lines
7.5 KiB
TypeScript

import type { CodeAction, CodeActionParams, TextEdit, Range } from 'vscode-languageserver'
import { State } from '../util/state'
import { InvalidApplyDiagnostic } from '../diagnostics/types'
import { isCssDoc } from '../util/css'
import { getLanguageBoundaries } from '../util/getLanguageBoundaries'
import { getClassNameMeta } from '../util/getClassNameMeta'
import { getClassNameParts } from '../util/getClassNameAtPosition'
import { validateApply } from '../util/validateApply'
import { isWithinRange } from '../util/isWithinRange'
import dlv from 'dlv'
import type { Root, Source } from 'postcss'
import { absoluteRange } from '../util/absoluteRange'
import { removeRangesFromString } from '../util/removeRangesFromString'
import detectIndent from 'detect-indent'
import isObject from '../util/isObject'
import { cssObjToAst } from '../util/cssObjToAst'
import { dset } from 'dset'
import selectorParser from 'postcss-selector-parser'
import { flatten } from '../util/array'
import { getTextWithoutComments } from '../util/doc'
export async function provideInvalidApplyCodeActions(
state: State,
params: CodeActionParams,
diagnostic: InvalidApplyDiagnostic
): Promise<CodeAction[]> {
let document = state.editor.documents.get(params.textDocument.uri)
if (!document) return []
let documentText = getTextWithoutComments(document, 'css')
let cssRange: Range
let cssText = documentText
const { postcss } = state.modules
let changes: TextEdit[] = []
let totalClassNamesInClassList = diagnostic.className.classList.classList.split(/\s+/).length
let className = diagnostic.className.className
let classNameParts = getClassNameParts(state, className)
let classNameInfo = dlv(state.classNames.classNames, classNameParts)
if (Array.isArray(classNameInfo)) {
return []
}
if (!isCssDoc(state, document)) {
let languageBoundaries = getLanguageBoundaries(state, document)
if (!languageBoundaries) return []
cssRange = languageBoundaries
.filter((b) => b.type === 'css')
.find(({ range }) => isWithinRange(diagnostic.range.start, range))?.range
if (!cssRange) return []
cssText = getTextWithoutComments(document, 'css', cssRange)
}
try {
await postcss
.module([
// TODO: use plain function?
// @ts-ignore
postcss.module.plugin('', (_options = {}) => {
return (root: Root) => {
root.walkRules((rule) => {
if (changes.length) return false
rule.walkAtRules('apply', (atRule) => {
let atRuleRange = postcssSourceToRange(atRule.source)
if (cssRange) {
atRuleRange = absoluteRange(atRuleRange, cssRange)
}
if (!isWithinRange(diagnostic.range.start, atRuleRange)) return undefined // true
let ast = classNameToAst(
state,
classNameParts,
rule.selector,
diagnostic.className.classList.important
)
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(cssText)
changes.push({
range: ruleRange,
newText:
rule.toString() +
(insertedRule.raws.before || '\n\n') +
insertedRule
.toString()
.replace(/\n\s*\n/g, '\n')
.replace(/(@apply [^;\n]+)$/gm, '$1;')
.replace(/([^\s^]){$/gm, '$1 {')
.replace(/^\s+/gm, (m: string) => {
if (typeof outputIndent === 'undefined') outputIndent = m
return m.replace(new RegExp(outputIndent, 'g'), documentIndent.indent)
})
.replace(/^(\s+)(.*?[^{}]\n)([^\s}])/gm, '$1$2$1$3'),
})
return false
})
return undefined // true
})
}
}),
])
.process(cssText, { from: undefined })
} catch (_) {
return []
}
if (!changes.length) {
return []
}
return [
{
title: 'Extract to new rule',
kind: 'quickfix', // CodeActionKind.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[params.textDocument.uri]: changes,
},
},
},
]
}
function postcssSourceToRange(source: Source): Range {
return {
start: {
line: source.start.line - 1,
character: source.start.column - 1,
},
end: {
line: source.end.line - 1,
character: source.end.column,
},
}
}
function classNameToAst(
state: State,
classNameParts: string[],
selector: string,
important: boolean = false
) {
const baseClassName = classNameParts[classNameParts.length - 1]
const validatedBaseClassName = validateApply(state, [baseClassName])
if (validatedBaseClassName === null || validatedBaseClassName.isApplyable === false) {
return null
}
const meta = getClassNameMeta(state, classNameParts)
if (Array.isArray(meta)) return null
let context = meta.context
let pseudo = meta.pseudo
const globalContexts = state.classNames.context
const path = []
for (let i = 0; i < classNameParts.length - 1; i++) {
let part = classNameParts[i]
let common = globalContexts[part]
if (!common) return null
if (state.screens.includes(part)) {
path.push(`@screen ${part}`)
context = context.filter((con) => !common.includes(con))
}
}
path.push(...context)
let obj = {}
for (let i = 1; i <= path.length; i++) {
dset(obj, path.slice(0, i), {})
}
selector = appendPseudosToSelector(selector, pseudo)
if (selector === null) return null
let rule = {
[selector]: {
[`@apply ${baseClassName}${important ? ' !important' : ''}`]: '',
},
}
if (path.length) {
dset(obj, path, rule)
} else {
obj = rule
}
return cssObjToAst(obj, state.modules.postcss)
}
function appendPseudosToSelector(selector: string, pseudos: string[]): string | null {
if (pseudos.length === 0) return selector
let canTransform = true
let transformedSelector = selectorParser((selectors) => {
flatten(selectors.split((_) => true)).forEach((sel) => {
// @ts-ignore
for (let i = sel.nodes.length - 1; i >= 0; i--) {
// @ts-ignore
if (sel.nodes[i].type !== 'pseudo') {
break
// @ts-ignore
} else if (pseudos.includes(sel.nodes[i].value)) {
canTransform = false
break
}
}
if (canTransform) {
pseudos.forEach((p) => {
// @ts-ignore
sel.append(selectorParser.pseudo({ value: p }))
})
}
})
}).processSync(selector)
if (!canTransform) return null
return transformedSelector
}