diff --git a/src/lsp/providers/codeActions/codeActionProvider.ts b/src/lsp/providers/codeActions/codeActionProvider.ts index 62d2f09..1ad8c55 100644 --- a/src/lsp/providers/codeActions/codeActionProvider.ts +++ b/src/lsp/providers/codeActions/codeActionProvider.ts @@ -1,39 +1,21 @@ -import { - CodeAction, - CodeActionParams, - CodeActionKind, - Range, - TextEdit, -} from 'vscode-languageserver' +import { CodeAction, CodeActionParams } from 'vscode-languageserver' import { State } from '../../util/state' -import { isWithinRange } from '../../util/isWithinRange' -import { getClassNameParts } from '../../util/getClassNameAtPosition' -const dlv = require('dlv') -import dset from 'dset' -import { removeRangesFromString } from '../../util/removeRangesFromString' -import detectIndent from 'detect-indent' -import { cssObjToAst } from '../../util/cssObjToAst' -import isObject from '../../../util/isObject' import { getDiagnostics } from '../diagnostics/diagnosticsProvider' import { rangesEqual } from '../../util/rangesEqual' import { DiagnosticKind, isInvalidApplyDiagnostic, AugmentedDiagnostic, - InvalidApplyDiagnostic, isUtilityConflictsDiagnostic, - UtilityConflictsDiagnostic, isInvalidConfigPathDiagnostic, isInvalidTailwindDirectiveDiagnostic, isInvalidScreenDiagnostic, isInvalidVariantDiagnostic, } from '../diagnostics/types' import { flatten, dedupeBy } from '../../../util/array' -import { joinWithAnd } from '../../util/joinWithAnd' -import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' -import { isCssDoc } from '../../util/css' -import { absoluteRange } from '../../util/absoluteRange' -import type { NodeSource, Root } from 'postcss' +import { provideUtilityConflictsCodeActions } from './provideUtilityConflictsCodeActions' +import { provideInvalidApplyCodeActions } from './provideInvalidApplyCodeActions' +import { provideSuggestionCodeActions } from './provideSuggestionCodeActions' async function getDiagnosticsFromCodeActionParams( state: State, @@ -60,287 +42,36 @@ export async function provideCodeActions( state: State, params: CodeActionParams ): Promise { - let codes = params.context.diagnostics - .map((diagnostic) => diagnostic.code) - .filter(Boolean) as DiagnosticKind[] - let diagnostics = await getDiagnosticsFromCodeActionParams( state, params, - codes + params.context.diagnostics + .map((diagnostic) => diagnostic.code) + .filter(Boolean) as DiagnosticKind[] ) - let actions = diagnostics.map((diagnostic) => { - if (isInvalidApplyDiagnostic(diagnostic)) { - return provideInvalidApplyCodeActions(state, params, diagnostic) - } + return Promise.all( + diagnostics.map((diagnostic) => { + if (isInvalidApplyDiagnostic(diagnostic)) { + return provideInvalidApplyCodeActions(state, params, diagnostic) + } - if (isUtilityConflictsDiagnostic(diagnostic)) { - return provideUtilityConflictsCodeActions(state, params, diagnostic) - } + if (isUtilityConflictsDiagnostic(diagnostic)) { + return provideUtilityConflictsCodeActions(state, params, diagnostic) + } - if ( - isInvalidConfigPathDiagnostic(diagnostic) || - isInvalidTailwindDirectiveDiagnostic(diagnostic) || - isInvalidScreenDiagnostic(diagnostic) || - isInvalidVariantDiagnostic(diagnostic) - ) { - return diagnostic.suggestions.map((suggestion) => ({ - title: `Replace with '${suggestion}'`, - kind: CodeActionKind.QuickFix, - diagnostics: [diagnostic], - edit: { - changes: { - [params.textDocument.uri]: [ - { - range: diagnostic.range, - newText: suggestion, - }, - ], - }, - }, - })) - } + if ( + isInvalidConfigPathDiagnostic(diagnostic) || + isInvalidTailwindDirectiveDiagnostic(diagnostic) || + isInvalidScreenDiagnostic(diagnostic) || + isInvalidVariantDiagnostic(diagnostic) + ) { + return provideSuggestionCodeActions(state, params, diagnostic) + } - return [] - }) - - return Promise.all(actions) + return [] + }) + ) .then(flatten) .then((x) => dedupeBy(x, (item) => JSON.stringify(item.edit))) } - -function classNameToAst( - state: State, - classNameParts: string[], - selector: string, - important: boolean = false -) { - const baseClassName = dlv( - state.classNames.classNames, - classNameParts[classNameParts.length - 1] - ) - if (!baseClassName) { - return null - } - const info = dlv(state.classNames.classNames, classNameParts) - 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 < classNameParts.length - 1; i++) { - let part = classNameParts[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 ${classNameParts[classNameParts.length - 1]}${ - important ? ' !important' : '' - }`]: '', - }, - } - if (path.length) { - dset(obj, path, rule) - } else { - obj = rule - } - - return cssObjToAst(obj, state.modules.postcss) -} - -async function provideUtilityConflictsCodeActions( - state: State, - params: CodeActionParams, - diagnostic: UtilityConflictsDiagnostic -): Promise { - return [ - { - title: `Delete ${joinWithAnd( - diagnostic.otherClassNames.map( - (otherClassName) => `'${otherClassName.className}'` - ) - )}`, - kind: CodeActionKind.QuickFix, - diagnostics: [diagnostic], - edit: { - changes: { - [params.textDocument.uri]: [ - { - range: diagnostic.className.classList.range, - newText: removeRangesFromString( - diagnostic.className.classList.classList, - diagnostic.otherClassNames.map( - (otherClassName) => otherClassName.relativeRange - ) - ), - }, - ], - }, - }, - }, - ] -} - -function postcssSourceToRange(source: NodeSource): Range { - return { - start: { - line: source.start.line - 1, - character: source.start.column - 1, - }, - end: { - line: source.end.line - 1, - character: source.end.column, - }, - } -} - -async function provideInvalidApplyCodeActions( - state: State, - params: CodeActionParams, - diagnostic: InvalidApplyDiagnostic -): Promise { - let document = state.editor.documents.get(params.textDocument.uri) - let documentText = document.getText() - 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.css.find((range) => - isWithinRange(diagnostic.range.start, range) - ) - if (!cssRange) return [] - cssText = document.getText(cssRange) - } - - try { - await postcss([ - postcss.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 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(documentText) - - 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 - ) - }), - }) - - return false - }) - }) - } - }), - ]).process(cssText, { from: undefined }) - } catch (_) { - return [] - } - - if (!changes.length) { - return [] - } - - return [ - { - title: 'Extract to new rule', - kind: CodeActionKind.QuickFix, - diagnostics: [diagnostic], - edit: { - changes: { - [params.textDocument.uri]: changes, - }, - }, - }, - ] -} diff --git a/src/lsp/providers/codeActions/provideInvalidApplyCodeActions.ts b/src/lsp/providers/codeActions/provideInvalidApplyCodeActions.ts new file mode 100644 index 0000000..a0412aa --- /dev/null +++ b/src/lsp/providers/codeActions/provideInvalidApplyCodeActions.ts @@ -0,0 +1,223 @@ +import { + CodeAction, + CodeActionParams, + CodeActionKind, + TextEdit, + Range, +} from 'vscode-languageserver' +import { State } from '../../util/state' +import { InvalidApplyDiagnostic } from '../diagnostics/types' +import { getClassNameParts } from '../../util/getClassNameAtPosition' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { isCssDoc } from '../../util/css' +import { isWithinRange } from '../../util/isWithinRange' +const dlv = require('dlv') +import type { Root, NodeSource } 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' + +export async function provideInvalidApplyCodeActions( + state: State, + params: CodeActionParams, + diagnostic: InvalidApplyDiagnostic +): Promise { + let document = state.editor.documents.get(params.textDocument.uri) + let documentText = document.getText() + 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.css.find((range) => + isWithinRange(diagnostic.range.start, range) + ) + if (!cssRange) return [] + cssText = document.getText(cssRange) + } + + try { + await postcss([ + postcss.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 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(documentText) + + 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 + ) + }), + }) + + return false + }) + }) + } + }), + ]).process(cssText, { from: undefined }) + } catch (_) { + return [] + } + + if (!changes.length) { + return [] + } + + return [ + { + title: 'Extract to new rule', + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: changes, + }, + }, + }, + ] +} + +function postcssSourceToRange(source: NodeSource): 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 = dlv( + state.classNames.classNames, + classNameParts[classNameParts.length - 1] + ) + if (!baseClassName) { + return null + } + const info = dlv(state.classNames.classNames, classNameParts) + 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 < classNameParts.length - 1; i++) { + let part = classNameParts[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 ${classNameParts[classNameParts.length - 1]}${ + important ? ' !important' : '' + }`]: '', + }, + } + if (path.length) { + dset(obj, path, rule) + } else { + obj = rule + } + + return cssObjToAst(obj, state.modules.postcss) +} diff --git a/src/lsp/providers/codeActions/provideSuggestionCodeActions.ts b/src/lsp/providers/codeActions/provideSuggestionCodeActions.ts new file mode 100644 index 0000000..9da5fcb --- /dev/null +++ b/src/lsp/providers/codeActions/provideSuggestionCodeActions.ts @@ -0,0 +1,38 @@ +import { State } from '../../util/state' +import { + CodeActionParams, + CodeAction, + CodeActionKind, +} from 'vscode-languageserver' +import { + InvalidConfigPathDiagnostic, + InvalidTailwindDirectiveDiagnostic, + InvalidScreenDiagnostic, + InvalidVariantDiagnostic, +} from '../diagnostics/types' + +export function provideSuggestionCodeActions( + _state: State, + params: CodeActionParams, + diagnostic: + | InvalidConfigPathDiagnostic + | InvalidTailwindDirectiveDiagnostic + | InvalidScreenDiagnostic + | InvalidVariantDiagnostic +): CodeAction[] { + return diagnostic.suggestions.map((suggestion) => ({ + title: `Replace with '${suggestion}'`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.range, + newText: suggestion, + }, + ], + }, + }, + })) +} diff --git a/src/lsp/providers/codeActions/provideUtilityConflictsCodeActions.ts b/src/lsp/providers/codeActions/provideUtilityConflictsCodeActions.ts new file mode 100644 index 0000000..007cd88 --- /dev/null +++ b/src/lsp/providers/codeActions/provideUtilityConflictsCodeActions.ts @@ -0,0 +1,42 @@ +import { State } from '../../util/state' +import { + CodeActionParams, + CodeAction, + CodeActionKind, +} from 'vscode-languageserver' +import { UtilityConflictsDiagnostic } from '../diagnostics/types' +import { joinWithAnd } from '../../util/joinWithAnd' +import { removeRangesFromString } from '../../util/removeRangesFromString' + +export async function provideUtilityConflictsCodeActions( + _state: State, + params: CodeActionParams, + diagnostic: UtilityConflictsDiagnostic +): Promise { + return [ + { + title: `Delete ${joinWithAnd( + diagnostic.otherClassNames.map( + (otherClassName) => `'${otherClassName.className}'` + ) + )}`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.className.classList.range, + newText: removeRangesFromString( + diagnostic.className.classList.classList, + diagnostic.otherClassNames.map( + (otherClassName) => otherClassName.relativeRange + ) + ), + }, + ], + }, + }, + }, + ] +}