Theme helper improvements

master
Brad Cornes 2022-10-17 17:56:00 +01:00
parent c9acd0d124
commit 1b730cb656
9 changed files with 179 additions and 156 deletions

View File

@ -162,11 +162,16 @@ connection.onCompletion(async ({ textDocument, position }, _token) =>
{ {
...item, ...item,
label: 'theme()', label: 'theme()',
filterText: 'theme',
documentation: { documentation: {
kind: 'markdown', kind: 'markdown',
value: value:
'Use the `theme()` function to access your Tailwind config values using dot notation.', 'Use the `theme()` function to access your Tailwind config values using dot notation.',
}, },
command: {
title: '',
command: 'editor.action.triggerSuggest',
},
textEdit: { textEdit: {
...item.textEdit, ...item.textEdit,
newText: item.textEdit.newText.replace(/^calc\(/, 'theme('), newText: item.textEdit.newText.replace(/^calc\(/, 'theme('),
@ -357,6 +362,7 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
/@media(\s+screen\s*\([^)]+\))/g, /@media(\s+screen\s*\([^)]+\))/g,
(_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}` (_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`
) )
.replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_')
) )
} }

View File

@ -112,6 +112,7 @@ const TRIGGER_CHARACTERS = [
// @apply and emmet-style // @apply and emmet-style
'.', '.',
// config/theme helper // config/theme helper
'(',
'[', '[',
// JIT "important" prefix // JIT "important" prefix
'!', '!',

View File

@ -503,6 +503,11 @@ function provideAtApplyCompletions(
) )
} }
const NUMBER_REGEX = /^(\d+\.?|\d*\.\d+)$/
function isNumber(str: string): boolean {
return NUMBER_REGEX.test(str)
}
async function provideClassNameCompletions( async function provideClassNameCompletions(
state: State, state: State,
document: TextDocument, document: TextDocument,
@ -537,14 +542,26 @@ function provideCssHelperCompletions(
const match = text const match = text
.substr(0, text.length - 1) // don't include that extra character from earlier .substr(0, text.length - 1) // don't include that extra character from earlier
.match(/\b(?<helper>config|theme)\(['"](?<keys>[^'"]*)$/) .match(/\b(?<helper>config|theme)\(\s*['"]?(?<path>[^)'"]*)$/)
if (match === null) { if (match === null) {
return null return null
} }
let alpha: string
let path = match.groups.path.replace(/^['"]+/g, '')
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]*))$/)
if (matches) {
path = matches[1]
alpha = matches[2]
}
if (alpha !== undefined) {
return null
}
let base = match.groups.helper === 'config' ? state.config : dlv(state.config, 'theme', {}) let base = match.groups.helper === 'config' ? state.config : dlv(state.config, 'theme', {})
let parts = match.groups.keys.split(/([\[\].]+)/) let parts = path.split(/([\[\].]+)/)
let keys = parts.filter((_, i) => i % 2 === 0) let keys = parts.filter((_, i) => i % 2 === 0)
let separators = parts.filter((_, i) => i % 2 !== 0) let separators = parts.filter((_, i) => i % 2 !== 0)
// let obj = // let obj =
@ -557,7 +574,7 @@ function provideCssHelperCompletions(
} }
let obj: any let obj: any
let offset: number = 0 let offset: number = keys[keys.length - 1].length
let separator: string = separators.length ? separators[separators.length - 1] : null let separator: string = separators.length ? separators[separators.length - 1] : null
if (keys.length === 1) { if (keys.length === 1) {
@ -576,9 +593,32 @@ function provideCssHelperCompletions(
if (!obj) return null if (!obj) return null
let editRange = {
start: {
line: position.line,
character: position.character - offset,
},
end: position,
}
return { return {
isIncomplete: false, isIncomplete: false,
items: Object.keys(obj).map((item, index) => { items: Object.keys(obj)
.sort((a, z) => {
let aIsNumber = isNumber(a)
let zIsNumber = isNumber(z)
if (aIsNumber && !zIsNumber) {
return -1
}
if (!aIsNumber && zIsNumber) {
return 1
}
if (aIsNumber && zIsNumber) {
return parseFloat(a) - parseFloat(z)
}
return 0
})
.map((item, index) => {
let color = getColorFromValue(obj[item]) let color = getColorFromValue(obj[item])
const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.') const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.')
const insertClosingBrace: boolean = const insertClosingBrace: boolean =
@ -588,8 +628,10 @@ function provideCssHelperCompletions(
return { return {
label: item, label: item,
filterText: `${replaceDot ? '.' : ''}${item}`,
sortText: naturalExpand(index), sortText: naturalExpand(index),
commitCharacters: [!item.includes('.') && '.', !item.includes('[') && '['].filter(
Boolean
),
kind: color ? 16 : isObject(obj[item]) ? 9 : 10, kind: color ? 16 : isObject(obj[item]) ? 9 : 10,
// VS Code bug causes some values to not display in some cases // VS Code bug causes some values to not display in some cases
detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail, detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail,
@ -598,16 +640,23 @@ function provideCssHelperCompletions(
? culori.formatRgb(color) ? culori.formatRgb(color)
: null, : null,
textEdit: { textEdit: {
newText: `${replaceDot ? '[' : ''}${item}${insertClosingBrace ? ']' : ''}`, newText: `${item}${insertClosingBrace ? ']' : ''}`,
range: editRange,
},
additionalTextEdits: replaceDot
? [
{
newText: '[',
range: { range: {
start: { start: {
line: position.line, ...editRange.start,
character: character: editRange.start.character - 1,
position.character - keys[keys.length - 1].length - (replaceDot ? 1 : 0) - offset,
}, },
end: position, end: editRange.start,
}, },
}, },
]
: [],
data: 'helper', data: 'helper',
} }
}), }),

View File

@ -1,16 +1,12 @@
import { State, Settings } from '../util/state' import { State, Settings } from '../util/state'
import type { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' import type { TextDocument } from 'vscode-languageserver'
import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types' import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types'
import { isCssDoc } from '../util/css' import { findHelperFunctionsInDocument } from '../util/find'
import { getLanguageBoundaries } from '../util/getLanguageBoundaries'
import { findAll, indexToPosition } from '../util/find'
import { stringToPath } from '../util/stringToPath' import { stringToPath } from '../util/stringToPath'
import isObject from '../util/isObject' import isObject from '../util/isObject'
import { closest } from '../util/closest' import { closest } from '../util/closest'
import { absoluteRange } from '../util/absoluteRange'
import { combinations } from '../util/combinations' import { combinations } from '../util/combinations'
import dlv from 'dlv' import dlv from 'dlv'
import { getTextWithoutComments } from '../util/doc'
function pathToString(path: string | string[]): string { function pathToString(path: string | string[]): string {
if (typeof path === 'string') return path if (typeof path === 'string') return path
@ -167,47 +163,18 @@ export function getInvalidConfigPathDiagnostics(
if (severity === 'ignore') return [] if (severity === 'ignore') return []
let diagnostics: InvalidConfigPathDiagnostic[] = [] let diagnostics: InvalidConfigPathDiagnostic[] = []
let ranges: Range[] = []
if (isCssDoc(state, document)) { findHelperFunctionsInDocument(state, document).forEach((helperFn) => {
ranges.push(undefined) let base = helperFn.helper === 'theme' ? ['theme'] : []
} else { let result = validateConfigPath(state, helperFn.path, base)
let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return []
ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
}
ranges.forEach((range) => {
let text = getTextWithoutComments(document, 'css', range)
let matches = findAll(
/(?<prefix>\s|^)(?<helper>config|theme)\((?<quote>['"])(?<key>[^)]+)\k<quote>[^)]*\)/g,
text
)
matches.forEach((match) => {
let base = match.groups.helper === 'theme' ? ['theme'] : []
let result = validateConfigPath(state, match.groups.key, base)
if (result.isValid === true) { if (result.isValid === true) {
return null return
} }
let startIndex =
match.index +
match.groups.prefix.length +
match.groups.helper.length +
1 + // open paren
match.groups.quote.length
diagnostics.push({ diagnostics.push({
code: DiagnosticKind.InvalidConfigPath, code: DiagnosticKind.InvalidConfigPath,
range: absoluteRange( range: helperFn.ranges.path,
{
start: indexToPosition(text, startIndex),
end: indexToPosition(text, startIndex + match.groups.key.length),
},
range
),
severity: severity:
severity === 'error' severity === 'error'
? 1 /* DiagnosticSeverity.Error */ ? 1 /* DiagnosticSeverity.Error */
@ -216,7 +183,6 @@ export function getInvalidConfigPathDiagnostics(
suggestions: result.suggestions, suggestions: result.suggestions,
}) })
}) })
})
return diagnostics return diagnostics
} }

View File

@ -36,12 +36,12 @@ export async function getDocumentColors(
let helperFns = findHelperFunctionsInDocument(state, document) let helperFns = findHelperFunctionsInDocument(state, document)
helperFns.forEach((fn) => { helperFns.forEach((fn) => {
let keys = stringToPath(fn.value) let keys = stringToPath(fn.path)
let base = fn.helper === 'theme' ? ['theme'] : [] let base = fn.helper === 'theme' ? ['theme'] : []
let value = dlv(state.config, [...base, ...keys]) let value = dlv(state.config, [...base, ...keys])
let color = getColorFromValue(value) let color = getColorFromValue(value)
if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) { if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) {
colors.push({ range: fn.valueRange, color: culoriColorToVscodeColor(color) }) colors.push({ range: fn.ranges.path, color: culoriColorToVscodeColor(color) })
} }
}) })

View File

@ -3,12 +3,12 @@ import type { Hover, TextDocument, Position } from 'vscode-languageserver'
import { stringifyCss, stringifyConfigValue } from './util/stringify' import { stringifyCss, stringifyConfigValue } from './util/stringify'
import dlv from 'dlv' import dlv from 'dlv'
import { isCssContext } from './util/css' import { isCssContext } from './util/css'
import { findClassNameAtPosition } from './util/find' import { findClassNameAtPosition, findHelperFunctionsInRange } from './util/find'
import { validateApply } from './util/validateApply' import { validateApply } from './util/validateApply'
import { getClassNameParts } from './util/getClassNameAtPosition' import { getClassNameParts } from './util/getClassNameAtPosition'
import * as jit from './util/jit' import * as jit from './util/jit'
import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics' import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics'
import { getTextWithoutComments } from './util/doc' import { isWithinRange } from './util/isWithinRange'
export async function doHover( export async function doHover(
state: State, state: State,
@ -22,49 +22,34 @@ export async function doHover(
} }
function provideCssHelperHover(state: State, document: TextDocument, position: Position): Hover { function provideCssHelperHover(state: State, document: TextDocument, position: Position): Hover {
if (!isCssContext(state, document, position)) return null if (!isCssContext(state, document, position)) {
const line = getTextWithoutComments(document, 'css').split('\n')[position.line]
const match = line.match(/(?<helper>theme|config)\((?<quote>['"])(?<key>[^)]+)\k<quote>[^)]*\)/)
if (match === null) return null
const startChar = match.index + match.groups.helper.length + 2
const endChar = startChar + match.groups.key.length
if (position.character < startChar || position.character >= endChar) {
return null return null
} }
let key = match.groups.key let helperFns = findHelperFunctionsInRange(document, {
.split(/(\[[^\]]+\]|\.)/) start: { line: position.line, character: 0 },
.filter(Boolean) end: { line: position.line + 1, character: 0 },
.filter((x) => x !== '.') })
.map((x) => x.replace(/^\[([^\]]+)\]$/, '$1'))
if (key.length === 0) return null for (let helperFn of helperFns) {
if (isWithinRange(position, helperFn.ranges.path)) {
if (match.groups.helper === 'theme') { let validated = validateConfigPath(
key = ['theme', ...key] state,
helperFn.path,
helperFn.helper === 'theme' ? ['theme'] : []
)
let value = validated.isValid ? stringifyConfigValue(validated.value) : null
if (value === null) {
return null
} }
const value = validateConfigPath(state, key).isValid
? stringifyConfigValue(dlv(state.config, key))
: null
if (value === null) return null
return { return {
contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') }, contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') },
range: { range: helperFn.ranges.path,
start: { line: position.line, character: startChar },
end: {
line: position.line,
character: endChar,
},
},
} }
}
}
return null
} }
async function provideClassNameHover( async function provideClassNameHover(

View File

@ -359,36 +359,48 @@ export function findHelperFunctionsInRange(
range?: Range range?: Range
): DocumentHelperFunction[] { ): DocumentHelperFunction[] {
const text = getTextWithoutComments(doc, 'css', range) const text = getTextWithoutComments(doc, 'css', range)
const matches = findAll( let matches = findAll(
/(?<before>^|\s)(?<helper>theme|config)\((?:(?<single>')([^']+)'|(?<double>")([^"]+)")[^)]*\)/gm, /(?<prefix>\s|^)(?<helper>config|theme)(?<innerPrefix>\(\s*)(?<path>[^)]*?)\s*\)/g,
text text
) )
return matches.map((match) => { return matches.map((match) => {
let value = match[4] || match[6] let quotesBefore = ''
let startIndex = match.index + match.groups.before.length let path = match.groups.path.replace(/['"]+$/, '').replace(/^['"]+/, (m) => {
quotesBefore = m
return ''
})
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/)
if (matches) {
path = matches[1]
}
path = path.replace(/['"]*\s*$/, '')
let startIndex =
match.index +
match.groups.prefix.length +
match.groups.helper.length +
match.groups.innerPrefix.length
return { return {
full: match[0].substr(match.groups.before.length),
value,
helper: match.groups.helper === 'theme' ? 'theme' : 'config', helper: match.groups.helper === 'theme' ? 'theme' : 'config',
quotes: match.groups.single ? "'" : '"', path,
range: resolveRange( ranges: {
full: resolveRange(
{ {
start: indexToPosition(text, startIndex), start: indexToPosition(text, startIndex),
end: indexToPosition(text, match.index + match[0].length), end: indexToPosition(text, startIndex + match.groups.path.length),
}, },
range range
), ),
valueRange: resolveRange( path: resolveRange(
{ {
start: indexToPosition(text, startIndex + match.groups.helper.length + 1), start: indexToPosition(text, startIndex + quotesBefore.length),
end: indexToPosition( end: indexToPosition(text, startIndex + quotesBefore.length + path.length),
text,
startIndex + match.groups.helper.length + 1 + 1 + value.length + 1
),
}, },
range range
), ),
},
} }
}) })
} }

View File

@ -124,12 +124,12 @@ export type DocumentClassName = {
} }
export type DocumentHelperFunction = { export type DocumentHelperFunction = {
full: string
helper: 'theme' | 'config' helper: 'theme' | 'config'
value: string path: string
quotes: '"' | "'" ranges: {
range: Range full: Range
valueRange: Range path: Range
}
} }
export type ClassNameMeta = { export type ClassNameMeta = {

View File

@ -378,7 +378,11 @@ export async function activate(context: ExtensionContext) {
async resolveCompletionItem(item, token, next) { async resolveCompletionItem(item, token, next) {
let result = await next(item, token) let result = await next(item, token)
let selections = Window.activeTextEditor.selections let selections = Window.activeTextEditor.selections
if (selections.length > 1 && result.additionalTextEdits?.length > 0) { if (
result['data'] === 'variant' &&
selections.length > 1 &&
result.additionalTextEdits?.length > 0
) {
let length = let length =
selections[0].start.character - result.additionalTextEdits[0].range.start.character selections[0].start.character - result.additionalTextEdits[0].range.start.character
let prefixLength = let prefixLength =