|
| 1 | +import { |
| 2 | + CodeAction, |
| 3 | + CodeActionParams, |
| 4 | + CodeActionKind, |
| 5 | + Range, |
| 6 | + TextEdit, |
| 7 | + Diagnostic, |
| 8 | +} from 'vscode-languageserver' |
| 9 | +import { State } from '../../util/state' |
| 10 | +import { findLast, findClassNamesInRange } from '../../util/find' |
| 11 | +import { isWithinRange } from '../../util/isWithinRange' |
| 12 | +import { getClassNameParts } from '../../util/getClassNameAtPosition' |
| 13 | +const dlv = require('dlv') |
| 14 | +import dset from 'dset' |
| 15 | +import { removeRangeFromString } from '../../util/removeRangeFromString' |
| 16 | +import detectIndent from 'detect-indent' |
| 17 | +import { cssObjToAst } from '../../util/cssObjToAst' |
| 18 | +import isObject from '../../../util/isObject' |
| 19 | + |
| 20 | +export function provideCodeActions( |
| 21 | + state: State, |
| 22 | + params: CodeActionParams |
| 23 | +): Promise<CodeAction[]> { |
| 24 | + if (params.context.diagnostics.length === 0) { |
| 25 | + return null |
| 26 | + } |
| 27 | + |
| 28 | + return Promise.all( |
| 29 | + params.context.diagnostics |
| 30 | + .map((diagnostic) => { |
| 31 | + if (diagnostic.code === 'invalidApply') { |
| 32 | + return provideInvalidApplyCodeAction(state, params, diagnostic) |
| 33 | + } |
| 34 | + |
| 35 | + let match = findLast( |
| 36 | + / Did you mean (?:something like )?'(?<replacement>[^']+)'\?$/g, |
| 37 | + diagnostic.message |
| 38 | + ) |
| 39 | + |
| 40 | + if (!match) { |
| 41 | + return null |
| 42 | + } |
| 43 | + |
| 44 | + return { |
| 45 | + title: `Replace with '${match.groups.replacement}'`, |
| 46 | + kind: CodeActionKind.QuickFix, |
| 47 | + diagnostics: [diagnostic], |
| 48 | + edit: { |
| 49 | + changes: { |
| 50 | + [params.textDocument.uri]: [ |
| 51 | + { |
| 52 | + range: diagnostic.range, |
| 53 | + newText: match.groups.replacement, |
| 54 | + }, |
| 55 | + ], |
| 56 | + }, |
| 57 | + }, |
| 58 | + } |
| 59 | + }) |
| 60 | + .filter(Boolean) |
| 61 | + ) |
| 62 | +} |
| 63 | + |
| 64 | +function classNameToAst( |
| 65 | + state: State, |
| 66 | + className: string, |
| 67 | + selector: string = `.${className}`, |
| 68 | + important: boolean = false |
| 69 | +) { |
| 70 | + const parts = getClassNameParts(state, className) |
| 71 | + if (!parts) { |
| 72 | + return null |
| 73 | + } |
| 74 | + const baseClassName = dlv( |
| 75 | + state.classNames.classNames, |
| 76 | + parts[parts.length - 1] |
| 77 | + ) |
| 78 | + if (!baseClassName) { |
| 79 | + return null |
| 80 | + } |
| 81 | + const info = dlv(state.classNames.classNames, parts) |
| 82 | + let context = info.__context || [] |
| 83 | + let pseudo = info.__pseudo || [] |
| 84 | + const globalContexts = state.classNames.context |
| 85 | + let screens = dlv( |
| 86 | + state.config, |
| 87 | + 'theme.screens', |
| 88 | + dlv(state.config, 'screens', {}) |
| 89 | + ) |
| 90 | + if (!isObject(screens)) screens = {} |
| 91 | + screens = Object.keys(screens) |
| 92 | + const path = [] |
| 93 | + |
| 94 | + for (let i = 0; i < parts.length - 1; i++) { |
| 95 | + let part = parts[i] |
| 96 | + let common = globalContexts[part] |
| 97 | + if (!common) return null |
| 98 | + if (screens.includes(part)) { |
| 99 | + path.push(`@screen ${part}`) |
| 100 | + context = context.filter((con) => !common.includes(con)) |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + path.push(...context) |
| 105 | + |
| 106 | + let obj = {} |
| 107 | + for (let i = 1; i <= path.length; i++) { |
| 108 | + dset(obj, path.slice(0, i), {}) |
| 109 | + } |
| 110 | + let rule = { |
| 111 | + // TODO: use proper selector parser |
| 112 | + [selector + pseudo.join('')]: { |
| 113 | + [`@apply ${parts[parts.length - 1]}${ |
| 114 | + important ? ' !important' : '' |
| 115 | + }`]: '', |
| 116 | + }, |
| 117 | + } |
| 118 | + if (path.length) { |
| 119 | + dset(obj, path, rule) |
| 120 | + } else { |
| 121 | + obj = rule |
| 122 | + } |
| 123 | + |
| 124 | + return cssObjToAst(obj, state.modules.postcss) |
| 125 | +} |
| 126 | + |
| 127 | +async function provideInvalidApplyCodeAction( |
| 128 | + state: State, |
| 129 | + params: CodeActionParams, |
| 130 | + diagnostic: Diagnostic |
| 131 | +): Promise<CodeAction> { |
| 132 | + let document = state.editor.documents.get(params.textDocument.uri) |
| 133 | + let documentText = document.getText() |
| 134 | + const { postcss } = state.modules |
| 135 | + let change: TextEdit |
| 136 | + |
| 137 | + let documentClassNames = findClassNamesInRange( |
| 138 | + document, |
| 139 | + { |
| 140 | + start: { |
| 141 | + line: Math.max(0, diagnostic.range.start.line - 10), |
| 142 | + character: 0, |
| 143 | + }, |
| 144 | + end: { line: diagnostic.range.start.line + 10, character: 0 }, |
| 145 | + }, |
| 146 | + 'css' |
| 147 | + ) |
| 148 | + let documentClassName = documentClassNames.find((className) => |
| 149 | + isWithinRange(diagnostic.range.start, className.range) |
| 150 | + ) |
| 151 | + if (!documentClassName) { |
| 152 | + return null |
| 153 | + } |
| 154 | + let totalClassNamesInClassList = documentClassName.classList.classList.split( |
| 155 | + /\s+/ |
| 156 | + ).length |
| 157 | + |
| 158 | + await postcss([ |
| 159 | + postcss.plugin('', (_options = {}) => { |
| 160 | + return (root) => { |
| 161 | + root.walkRules((rule) => { |
| 162 | + if (change) return false |
| 163 | + |
| 164 | + rule.walkAtRules('apply', (atRule) => { |
| 165 | + let { start, end } = atRule.source |
| 166 | + let range: Range = { |
| 167 | + start: { |
| 168 | + line: start.line - 1, |
| 169 | + character: start.column - 1, |
| 170 | + }, |
| 171 | + end: { |
| 172 | + line: end.line - 1, |
| 173 | + character: end.column - 1, |
| 174 | + }, |
| 175 | + } |
| 176 | + |
| 177 | + if (!isWithinRange(diagnostic.range.start, range)) { |
| 178 | + // keep looking |
| 179 | + return true |
| 180 | + } |
| 181 | + |
| 182 | + let className = document.getText(diagnostic.range) |
| 183 | + let ast = classNameToAst( |
| 184 | + state, |
| 185 | + className, |
| 186 | + rule.selector, |
| 187 | + documentClassName.classList.important |
| 188 | + ) |
| 189 | + |
| 190 | + if (!ast) { |
| 191 | + return false |
| 192 | + } |
| 193 | + |
| 194 | + rule.after(ast.nodes) |
| 195 | + let insertedRule = rule.next() |
| 196 | + |
| 197 | + if (totalClassNamesInClassList === 1) { |
| 198 | + atRule.remove() |
| 199 | + } |
| 200 | + |
| 201 | + let outputIndent: string |
| 202 | + let documentIndent = detectIndent(documentText) |
| 203 | + |
| 204 | + change = { |
| 205 | + range: { |
| 206 | + start: { |
| 207 | + line: rule.source.start.line - 1, |
| 208 | + character: rule.source.start.column - 1, |
| 209 | + }, |
| 210 | + end: { |
| 211 | + line: rule.source.end.line - 1, |
| 212 | + character: rule.source.end.column, |
| 213 | + }, |
| 214 | + }, |
| 215 | + newText: |
| 216 | + rule.toString() + |
| 217 | + (insertedRule.raws.before || '\n\n') + |
| 218 | + insertedRule |
| 219 | + .toString() |
| 220 | + .replace(/\n\s*\n/g, '\n') |
| 221 | + .replace(/(@apply [^;\n]+)$/gm, '$1;') |
| 222 | + .replace(/([^\s^]){$/gm, '$1 {') |
| 223 | + .replace(/^\s+/gm, (m: string) => { |
| 224 | + if (typeof outputIndent === 'undefined') outputIndent = m |
| 225 | + return m.replace( |
| 226 | + new RegExp(outputIndent, 'g'), |
| 227 | + documentIndent.indent |
| 228 | + ) |
| 229 | + }), |
| 230 | + } |
| 231 | + |
| 232 | + return false |
| 233 | + }) |
| 234 | + }) |
| 235 | + } |
| 236 | + }), |
| 237 | + ]).process(documentText, { from: undefined }) |
| 238 | + |
| 239 | + if (!change) { |
| 240 | + return null |
| 241 | + } |
| 242 | + |
| 243 | + return { |
| 244 | + title: 'Extract to new rule.', |
| 245 | + kind: CodeActionKind.QuickFix, |
| 246 | + diagnostics: [diagnostic], |
| 247 | + edit: { |
| 248 | + changes: { |
| 249 | + [params.textDocument.uri]: [ |
| 250 | + ...(totalClassNamesInClassList > 1 |
| 251 | + ? [ |
| 252 | + { |
| 253 | + range: documentClassName.classList.range, |
| 254 | + newText: removeRangeFromString( |
| 255 | + documentClassName.classList.classList, |
| 256 | + documentClassName.relativeRange |
| 257 | + ), |
| 258 | + }, |
| 259 | + ] |
| 260 | + : []), |
| 261 | + change, |
| 262 | + ], |
| 263 | + }, |
| 264 | + }, |
| 265 | + } |
| 266 | +} |
0 commit comments