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