From e79f72bda8bc04e3e77a1ca134b5d4f90c82cbab Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Wed, 17 Jun 2020 18:34:53 +0100 Subject: [PATCH] wip quick fix for invalid @apply --- package-lock.json | 6 + package.json | 1 + src/class-names/index.js | 4 + src/lsp/providers/codeActionProvider.ts | 45 --- src/lsp/providers/codeActionProvider/index.ts | 266 ++++++++++++++++++ src/lsp/providers/diagnosticsProvider.ts | 1 + src/lsp/server.ts | 10 +- src/lsp/util/cssObjToAst.ts | 127 +++++++++ src/lsp/util/find.ts | 16 +- src/lsp/util/getClassNameAtPosition.ts | 44 +-- src/lsp/util/logFull.ts | 5 + src/lsp/util/removeRangeFromString.ts | 16 ++ src/lsp/util/state.ts | 7 + 13 files changed, 455 insertions(+), 93 deletions(-) delete mode 100644 src/lsp/providers/codeActionProvider.ts create mode 100644 src/lsp/providers/codeActionProvider/index.ts create mode 100644 src/lsp/util/cssObjToAst.ts create mode 100644 src/lsp/util/logFull.ts create mode 100644 src/lsp/util/removeRangeFromString.ts diff --git a/package-lock.json b/package-lock.json index 2a7c7c5..1992a2d 100755 --- a/package-lock.json +++ b/package-lock.json @@ -2032,6 +2032,12 @@ "integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE=", "dev": true }, + "detect-indent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz", + "integrity": "sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==", + "dev": true + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", diff --git a/package.json b/package.json index e696a5d..bf55ce6 100755 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "chokidar": "^3.3.1", "concurrently": "^5.1.0", "css.escape": "^1.5.1", + "detect-indent": "^6.0.0", "dlv": "^1.1.3", "dset": "^2.0.1", "esm": "^3.2.25", diff --git a/src/class-names/index.js b/src/class-names/index.js index 247dc48..a856515 100644 --- a/src/class-names/index.js +++ b/src/class-names/index.js @@ -133,6 +133,10 @@ export default async function getClassNames( postcss, browserslist, }), + modules: { + tailwindcss, + postcss, + }, } } diff --git a/src/lsp/providers/codeActionProvider.ts b/src/lsp/providers/codeActionProvider.ts deleted file mode 100644 index 9900319..0000000 --- a/src/lsp/providers/codeActionProvider.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - CodeAction, - CodeActionParams, - CodeActionKind, -} from 'vscode-languageserver' -import { State } from '../util/state' -import { findLast } from '../util/find' - -export function provideCodeActions( - _state: State, - params: CodeActionParams -): CodeAction[] { - if (params.context.diagnostics.length === 0) { - return null - } - - return params.context.diagnostics - .map((diagnostic) => { - let match = findLast( - / Did you mean (?:something like )?'(?[^']+)'\?$/g, - diagnostic.message - ) - - if (!match) { - return null - } - - return { - title: `Replace with '${match.groups.replacement}'`, - kind: CodeActionKind.QuickFix, - diagnostics: [diagnostic], - edit: { - changes: { - [params.textDocument.uri]: [ - { - range: diagnostic.range, - newText: match.groups.replacement, - }, - ], - }, - }, - } - }) - .filter(Boolean) -} diff --git a/src/lsp/providers/codeActionProvider/index.ts b/src/lsp/providers/codeActionProvider/index.ts new file mode 100644 index 0000000..19d724e --- /dev/null +++ b/src/lsp/providers/codeActionProvider/index.ts @@ -0,0 +1,266 @@ +import { + CodeAction, + CodeActionParams, + CodeActionKind, + Range, + TextEdit, + Diagnostic, +} from 'vscode-languageserver' +import { State } from '../../util/state' +import { findLast, findClassNamesInRange } from '../../util/find' +import { isWithinRange } from '../../util/isWithinRange' +import { getClassNameParts } from '../../util/getClassNameAtPosition' +const dlv = require('dlv') +import dset from 'dset' +import { removeRangeFromString } from '../../util/removeRangeFromString' +import detectIndent from 'detect-indent' +import { cssObjToAst } from '../../util/cssObjToAst' +import isObject from '../../../util/isObject' + +export function provideCodeActions( + state: State, + params: CodeActionParams +): Promise { + if (params.context.diagnostics.length === 0) { + return null + } + + return Promise.all( + params.context.diagnostics + .map((diagnostic) => { + if (diagnostic.code === 'invalidApply') { + return provideInvalidApplyCodeAction(state, params, diagnostic) + } + + let match = findLast( + / Did you mean (?:something like )?'(?[^']+)'\?$/g, + diagnostic.message + ) + + if (!match) { + return null + } + + return { + title: `Replace with '${match.groups.replacement}'`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.range, + newText: match.groups.replacement, + }, + ], + }, + }, + } + }) + .filter(Boolean) + ) +} + +function classNameToAst( + state: State, + className: string, + selector: string = `.${className}`, + important: boolean = false +) { + const parts = getClassNameParts(state, className) + if (!parts) { + return null + } + const baseClassName = dlv( + state.classNames.classNames, + parts[parts.length - 1] + ) + if (!baseClassName) { + return null + } + const info = dlv(state.classNames.classNames, parts) + let context = info.__context || [] + let pseudo = info.__pseudo || [] + const globalContexts = state.classNames.context + let screens = dlv( + state.config, + 'theme.screens', + dlv(state.config, 'screens', {}) + ) + if (!isObject(screens)) screens = {} + screens = Object.keys(screens) + const path = [] + + for (let i = 0; i < parts.length - 1; i++) { + let part = parts[i] + let common = globalContexts[part] + if (!common) return null + if (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), {}) + } + let rule = { + // TODO: use proper selector parser + [selector + pseudo.join('')]: { + [`@apply ${parts[parts.length - 1]}${ + important ? ' !important' : '' + }`]: '', + }, + } + if (path.length) { + dset(obj, path, rule) + } else { + obj = rule + } + + return cssObjToAst(obj, state.modules.postcss) +} + +async function provideInvalidApplyCodeAction( + state: State, + params: CodeActionParams, + diagnostic: Diagnostic +): Promise { + let document = state.editor.documents.get(params.textDocument.uri) + let documentText = document.getText() + const { postcss } = state.modules + let change: TextEdit + + let documentClassNames = findClassNamesInRange( + document, + { + start: { + line: Math.max(0, diagnostic.range.start.line - 10), + character: 0, + }, + end: { line: diagnostic.range.start.line + 10, character: 0 }, + }, + 'css' + ) + let documentClassName = documentClassNames.find((className) => + isWithinRange(diagnostic.range.start, className.range) + ) + if (!documentClassName) { + return null + } + let totalClassNamesInClassList = documentClassName.classList.classList.split( + /\s+/ + ).length + + await postcss([ + postcss.plugin('', (_options = {}) => { + return (root) => { + root.walkRules((rule) => { + if (change) return false + + rule.walkAtRules('apply', (atRule) => { + let { start, end } = atRule.source + let range: Range = { + start: { + line: start.line - 1, + character: start.column - 1, + }, + end: { + line: end.line - 1, + character: end.column - 1, + }, + } + + if (!isWithinRange(diagnostic.range.start, range)) { + // keep looking + return true + } + + let className = document.getText(diagnostic.range) + let ast = classNameToAst( + state, + className, + rule.selector, + documentClassName.classList.important + ) + + if (!ast) { + return false + } + + rule.after(ast.nodes) + let insertedRule = rule.next() + + if (totalClassNamesInClassList === 1) { + atRule.remove() + } + + let outputIndent: string + let documentIndent = detectIndent(documentText) + + change = { + 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, + }, + }, + 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 + ) + }), + } + + return false + }) + }) + } + }), + ]).process(documentText, { from: undefined }) + + if (!change) { + return null + } + + return { + title: 'Extract to new rule.', + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + ...(totalClassNamesInClassList > 1 + ? [ + { + range: documentClassName.classList.range, + newText: removeRangeFromString( + documentClassName.classList.classList, + documentClassName.relativeRange + ), + }, + ] + : []), + change, + ], + }, + }, + } +} diff --git a/src/lsp/providers/diagnosticsProvider.ts b/src/lsp/providers/diagnosticsProvider.ts index 496dee3..f6fa909 100644 --- a/src/lsp/providers/diagnosticsProvider.ts +++ b/src/lsp/providers/diagnosticsProvider.ts @@ -73,6 +73,7 @@ function getInvalidApplyDiagnostics( : DiagnosticSeverity.Warning, range, message, + code: 'invalidApply', } }) .filter(Boolean) diff --git a/src/lsp/server.ts b/src/lsp/server.ts index dce9672..6c928e6 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -230,9 +230,11 @@ connection.onHover( } ) -connection.onCodeAction((params: CodeActionParams): CodeAction[] => { - if (!state.enabled) return null - return provideCodeActions(state, params) -}) +connection.onCodeAction( + (params: CodeActionParams): Promise => { + if (!state.enabled) return null + return provideCodeActions(state, params) + } +) connection.listen() diff --git a/src/lsp/util/cssObjToAst.ts b/src/lsp/util/cssObjToAst.ts new file mode 100644 index 0000000..42826f7 --- /dev/null +++ b/src/lsp/util/cssObjToAst.ts @@ -0,0 +1,127 @@ +/* +This is a modified version of the postcss-js 'parse' function which accepts the +postcss module as an argument. License below: + +The MIT License (MIT) + +Copyright 2015 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +var IMPORTANT = /\s*!important\s*$/i + +var unitless = { + 'box-flex': true, + 'box-flex-group': true, + 'column-count': true, + flex: true, + 'flex-grow': true, + 'flex-positive': true, + 'flex-shrink': true, + 'flex-negative': true, + 'font-weight': true, + 'line-clamp': true, + 'line-height': true, + opacity: true, + order: true, + orphans: true, + 'tab-size': true, + widows: true, + 'z-index': true, + zoom: true, + 'fill-opacity': true, + 'stroke-dashoffset': true, + 'stroke-opacity': true, + 'stroke-width': true, +} + +function dashify(str) { + return str + .replace(/([A-Z])/g, '-$1') + .replace(/^ms-/, '-ms-') + .toLowerCase() +} + +function decl(parent, name, value, postcss) { + if (value === false || value === null) return + + name = dashify(name) + if (typeof value === 'number') { + if (value === 0 || unitless[name]) { + value = value.toString() + } else { + value = value.toString() + 'px' + } + } + + if (name === 'css-float') name = 'float' + + if (IMPORTANT.test(value)) { + value = value.replace(IMPORTANT, '') + parent.push(postcss.decl({ prop: name, value: value, important: true })) + } else { + parent.push(postcss.decl({ prop: name, value: value })) + } +} + +function atRule(parent, parts, value, postcss) { + var node = postcss.atRule({ name: parts[1], params: parts[3] || '' }) + if (typeof value === 'object') { + node.nodes = [] + parse(value, node, postcss) + } + parent.push(node) +} + +function parse(obj, parent, postcss) { + var name, value, node, i + for (name in obj) { + if (obj.hasOwnProperty(name)) { + value = obj[name] + if (value === null || typeof value === 'undefined') { + continue + } else if (name[0] === '@') { + var parts = name.match(/@([^\s]+)(\s+([\w\W]*)\s*)?/) + if (Array.isArray(value)) { + for (i = 0; i < value.length; i++) { + atRule(parent, parts, value[i], postcss) + } + } else { + atRule(parent, parts, value, postcss) + } + } else if (Array.isArray(value)) { + for (i = 0; i < value.length; i++) { + decl(parent, name, value[i], postcss) + } + } else if (typeof value === 'object') { + node = postcss.rule({ selector: name }) + parse(value, node, postcss) + parent.push(node) + } else { + decl(parent, name, value, postcss) + } + } + } +} + +export function cssObjToAst(obj, postcss) { + var root = postcss.root() + parse(obj, root, postcss) + return root +} diff --git a/src/lsp/util/find.ts b/src/lsp/util/find.ts index 12dde01..b642534 100644 --- a/src/lsp/util/find.ts +++ b/src/lsp/util/find.ts @@ -32,6 +32,7 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray { export function getClassNamesInClassList({ classList, range, + important, }: DocumentClassList): DocumentClassName[] { const parts = classList.split(/(\s+)/) const names: DocumentClassName[] = [] @@ -42,6 +43,15 @@ export function getClassNamesInClassList({ const end = indexToPosition(classList, index + parts[i].length) names.push({ className: parts[i], + classList: { + classList, + range, + important, + }, + relativeRange: { + start, + end, + }, range: { start: { line: range.start.line + start.line, @@ -83,7 +93,10 @@ export function findClassListsInCssRange( range?: Range ): DocumentClassList[] { const text = doc.getText(range) - const matches = findAll(/(@apply\s+)(?[^;}]+)[;}]/g, text) + const matches = findAll( + /(@apply\s+)(?[^;}]+?)(?\s*!important)?\s*[;}]/g, + text + ) const globalStart: Position = range ? range.start : { line: 0, character: 0 } return matches.map((match) => { @@ -94,6 +107,7 @@ export function findClassListsInCssRange( ) return { classList: match.groups.classList, + important: Boolean(match.groups.important), range: { start: { line: globalStart.line + start.line, diff --git a/src/lsp/util/getClassNameAtPosition.ts b/src/lsp/util/getClassNameAtPosition.ts index 95de79a..083832c 100644 --- a/src/lsp/util/getClassNameAtPosition.ts +++ b/src/lsp/util/getClassNameAtPosition.ts @@ -1,48 +1,6 @@ -import { TextDocument, Range, Position } from 'vscode-languageserver' -import { State, DocumentClassName } from './state' +import { State } from './state' const dlv = require('dlv') -export function getClassNameAtPosition( - document: TextDocument, - position: Position -): DocumentClassName { - const range1: Range = { - start: { line: Math.max(position.line - 5, 0), character: 0 }, - end: position, - } - const text1: string = document.getText(range1) - - if (!/\bclass(Name)?=['"][^'"]*$/.test(text1)) return null - - const range2: Range = { - start: { line: Math.max(position.line - 5, 0), character: 0 }, - end: { line: position.line + 1, character: position.character }, - } - const text2: string = document.getText(range2) - - let str: string = text1 + text2.substr(text1.length).match(/^([^"' ]*)/)[0] - let matches: RegExpMatchArray = str.match(/\bclass(Name)?=["']([^"']+)$/) - - if (!matches) return null - - let className: string = matches[2].split(' ').pop() - if (!className) return null - - let range: Range = { - start: { - line: position.line, - character: - position.character + str.length - text1.length - className.length, - }, - end: { - line: position.line, - character: position.character + str.length - text1.length, - }, - } - - return { className, range } -} - export function getClassNameParts(state: State, className: string): string[] { let separator = state.separator className = className.replace(/^\./, '') diff --git a/src/lsp/util/logFull.ts b/src/lsp/util/logFull.ts new file mode 100644 index 0000000..c05fc1b --- /dev/null +++ b/src/lsp/util/logFull.ts @@ -0,0 +1,5 @@ +import * as util from 'util' + +export function logFull(object: any): void { + console.log(util.inspect(object, { showHidden: false, depth: null })) +} diff --git a/src/lsp/util/removeRangeFromString.ts b/src/lsp/util/removeRangeFromString.ts new file mode 100644 index 0000000..7479373 --- /dev/null +++ b/src/lsp/util/removeRangeFromString.ts @@ -0,0 +1,16 @@ +import { Range } from 'vscode-languageserver' +import lineColumn from 'line-column' + +export function removeRangeFromString(str: string, range: Range): string { + let finder = lineColumn(str + '\n', { origin: 0 }) + let start = finder.toIndex(range.start.line, range.start.character) + let end = finder.toIndex(range.end.line, range.end.character) + for (let i = start - 1; i >= 0; i--) { + if (/\s/.test(str.charAt(i))) { + start = i + } else { + break + } + } + return (str.substr(0, start) + str.substr(end)).trim() +} diff --git a/src/lsp/util/state.ts b/src/lsp/util/state.ts index 091650b..4158c59 100644 --- a/src/lsp/util/state.ts +++ b/src/lsp/util/state.ts @@ -48,6 +48,10 @@ export type State = null | { version?: string configPath?: string config?: any + modules?: { + tailwindcss: any + postcss: any + } separator?: string plugins?: any[] variants?: string[] @@ -60,11 +64,14 @@ export type State = null | { export type DocumentClassList = { classList: string range: Range + important?: boolean } export type DocumentClassName = { className: string range: Range + relativeRange: Range + classList: DocumentClassList } export type ClassNameMeta = {