From 1b730cb6568d8e19b160fe25bdb0b6f8157d174c Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 17 Oct 2022 17:56:00 +0100 Subject: [PATCH] Theme helper improvements --- .../src/language/cssServer.ts | 6 + .../tailwindcss-language-server/src/server.ts | 1 + .../src/completionProvider.ts | 117 +++++++++++++----- .../getInvalidConfigPathDiagnostics.ts | 68 +++------- .../src/documentColorProvider.ts | 4 +- .../src/hoverProvider.ts | 63 ++++------ .../src/util/find.ts | 60 +++++---- .../src/util/state.ts | 10 +- packages/vscode-tailwindcss/src/extension.ts | 6 +- 9 files changed, 179 insertions(+), 156 deletions(-) diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts index 496a251..731eca3 100644 --- a/packages/tailwindcss-language-server/src/language/cssServer.ts +++ b/packages/tailwindcss-language-server/src/language/cssServer.ts @@ -162,11 +162,16 @@ connection.onCompletion(async ({ textDocument, position }, _token) => { ...item, label: 'theme()', + filterText: 'theme', documentation: { kind: 'markdown', value: 'Use the `theme()` function to access your Tailwind config values using dot notation.', }, + command: { + title: '', + command: 'editor.action.triggerSuggest', + }, textEdit: { ...item.textEdit, newText: item.textEdit.newText.replace(/^calc\(/, 'theme('), @@ -357,6 +362,7 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument { /@media(\s+screen\s*\([^)]+\))/g, (_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}` ) + .replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_') ) } diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index dbc1f0b..a3b6131 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -112,6 +112,7 @@ const TRIGGER_CHARACTERS = [ // @apply and emmet-style '.', // config/theme helper + '(', '[', // JIT "important" prefix '!', diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 8ac5876..86f9fb7 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -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( state: State, document: TextDocument, @@ -537,14 +542,26 @@ function provideCssHelperCompletions( const match = text .substr(0, text.length - 1) // don't include that extra character from earlier - .match(/\b(?config|theme)\(['"](?[^'"]*)$/) + .match(/\b(?config|theme)\(\s*['"]?(?[^)'"]*)$/) if (match === 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 parts = match.groups.keys.split(/([\[\].]+)/) + let parts = path.split(/([\[\].]+)/) let keys = parts.filter((_, i) => i % 2 === 0) let separators = parts.filter((_, i) => i % 2 !== 0) // let obj = @@ -557,7 +574,7 @@ function provideCssHelperCompletions( } 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 if (keys.length === 1) { @@ -576,41 +593,73 @@ function provideCssHelperCompletions( if (!obj) return null + let editRange = { + start: { + line: position.line, + character: position.character - offset, + }, + end: position, + } + return { isIncomplete: false, - items: Object.keys(obj).map((item, index) => { - let color = getColorFromValue(obj[item]) - const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.') - const insertClosingBrace: boolean = - text.charAt(text.length - 1) !== ']' && - (replaceDot || (separator && separator.endsWith('['))) - const detail = stringifyConfigValue(obj[item]) + 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]) + const replaceDot: boolean = item.indexOf('.') !== -1 && separator && separator.endsWith('.') + const insertClosingBrace: boolean = + text.charAt(text.length - 1) !== ']' && + (replaceDot || (separator && separator.endsWith('['))) + const detail = stringifyConfigValue(obj[item]) - return { - label: item, - filterText: `${replaceDot ? '.' : ''}${item}`, - sortText: naturalExpand(index), - kind: color ? 16 : isObject(obj[item]) ? 9 : 10, - // VS Code bug causes some values to not display in some cases - detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail, - documentation: - color && typeof color !== 'string' && (color.alpha ?? 1) !== 0 - ? culori.formatRgb(color) - : null, - textEdit: { - newText: `${replaceDot ? '[' : ''}${item}${insertClosingBrace ? ']' : ''}`, - range: { - start: { - line: position.line, - character: - position.character - keys[keys.length - 1].length - (replaceDot ? 1 : 0) - offset, - }, - end: position, + return { + label: item, + sortText: naturalExpand(index), + commitCharacters: [!item.includes('.') && '.', !item.includes('[') && '['].filter( + Boolean + ), + kind: color ? 16 : isObject(obj[item]) ? 9 : 10, + // VS Code bug causes some values to not display in some cases + detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail, + documentation: + color && typeof color !== 'string' && (color.alpha ?? 1) !== 0 + ? culori.formatRgb(color) + : null, + textEdit: { + newText: `${item}${insertClosingBrace ? ']' : ''}`, + range: editRange, }, - }, - data: 'helper', - } - }), + additionalTextEdits: replaceDot + ? [ + { + newText: '[', + range: { + start: { + ...editRange.start, + character: editRange.start.character - 1, + }, + end: editRange.start, + }, + }, + ] + : [], + data: 'helper', + } + }), } } diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts index 716ce2c..0af3683 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts @@ -1,16 +1,12 @@ 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 { isCssDoc } from '../util/css' -import { getLanguageBoundaries } from '../util/getLanguageBoundaries' -import { findAll, indexToPosition } from '../util/find' +import { findHelperFunctionsInDocument } from '../util/find' import { stringToPath } from '../util/stringToPath' import isObject from '../util/isObject' import { closest } from '../util/closest' -import { absoluteRange } from '../util/absoluteRange' import { combinations } from '../util/combinations' import dlv from 'dlv' -import { getTextWithoutComments } from '../util/doc' function pathToString(path: string | string[]): string { if (typeof path === 'string') return path @@ -167,54 +163,24 @@ export function getInvalidConfigPathDiagnostics( if (severity === 'ignore') return [] let diagnostics: InvalidConfigPathDiagnostic[] = [] - let ranges: Range[] = [] - if (isCssDoc(state, document)) { - ranges.push(undefined) - } else { - let boundaries = getLanguageBoundaries(state, document) - if (!boundaries) return [] - ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range)) - } + findHelperFunctionsInDocument(state, document).forEach((helperFn) => { + let base = helperFn.helper === 'theme' ? ['theme'] : [] + let result = validateConfigPath(state, helperFn.path, base) - ranges.forEach((range) => { - let text = getTextWithoutComments(document, 'css', range) - let matches = findAll( - /(?\s|^)(?config|theme)\((?['"])(?[^)]+)\k[^)]*\)/g, - text - ) + if (result.isValid === true) { + return + } - matches.forEach((match) => { - let base = match.groups.helper === 'theme' ? ['theme'] : [] - let result = validateConfigPath(state, match.groups.key, base) - - if (result.isValid === true) { - return null - } - - let startIndex = - match.index + - match.groups.prefix.length + - match.groups.helper.length + - 1 + // open paren - match.groups.quote.length - - diagnostics.push({ - code: DiagnosticKind.InvalidConfigPath, - range: absoluteRange( - { - start: indexToPosition(text, startIndex), - end: indexToPosition(text, startIndex + match.groups.key.length), - }, - range - ), - severity: - severity === 'error' - ? 1 /* DiagnosticSeverity.Error */ - : 2 /* DiagnosticSeverity.Warning */, - message: result.reason, - suggestions: result.suggestions, - }) + diagnostics.push({ + code: DiagnosticKind.InvalidConfigPath, + range: helperFn.ranges.path, + severity: + severity === 'error' + ? 1 /* DiagnosticSeverity.Error */ + : 2 /* DiagnosticSeverity.Warning */, + message: result.reason, + suggestions: result.suggestions, }) }) diff --git a/packages/tailwindcss-language-service/src/documentColorProvider.ts b/packages/tailwindcss-language-service/src/documentColorProvider.ts index 081d1c0..dac832f 100644 --- a/packages/tailwindcss-language-service/src/documentColorProvider.ts +++ b/packages/tailwindcss-language-service/src/documentColorProvider.ts @@ -36,12 +36,12 @@ export async function getDocumentColors( let helperFns = findHelperFunctionsInDocument(state, document) helperFns.forEach((fn) => { - let keys = stringToPath(fn.value) + let keys = stringToPath(fn.path) let base = fn.helper === 'theme' ? ['theme'] : [] let value = dlv(state.config, [...base, ...keys]) let color = getColorFromValue(value) 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) }) } }) diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts index 090482e..f506bb2 100644 --- a/packages/tailwindcss-language-service/src/hoverProvider.ts +++ b/packages/tailwindcss-language-service/src/hoverProvider.ts @@ -3,12 +3,12 @@ import type { Hover, TextDocument, Position } from 'vscode-languageserver' import { stringifyCss, stringifyConfigValue } from './util/stringify' import dlv from 'dlv' import { isCssContext } from './util/css' -import { findClassNameAtPosition } from './util/find' +import { findClassNameAtPosition, findHelperFunctionsInRange } from './util/find' import { validateApply } from './util/validateApply' import { getClassNameParts } from './util/getClassNameAtPosition' import * as jit from './util/jit' import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics' -import { getTextWithoutComments } from './util/doc' +import { isWithinRange } from './util/isWithinRange' export async function doHover( state: State, @@ -22,49 +22,34 @@ export async function doHover( } function provideCssHelperHover(state: State, document: TextDocument, position: Position): Hover { - if (!isCssContext(state, document, position)) return null - - const line = getTextWithoutComments(document, 'css').split('\n')[position.line] - - const match = line.match(/(?theme|config)\((?['"])(?[^)]+)\k[^)]*\)/) - - 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) { + if (!isCssContext(state, document, position)) { return null } - let key = match.groups.key - .split(/(\[[^\]]+\]|\.)/) - .filter(Boolean) - .filter((x) => x !== '.') - .map((x) => x.replace(/^\[([^\]]+)\]$/, '$1')) + let helperFns = findHelperFunctionsInRange(document, { + start: { line: position.line, character: 0 }, + end: { line: position.line + 1, character: 0 }, + }) - if (key.length === 0) return null - - if (match.groups.helper === 'theme') { - key = ['theme', ...key] + for (let helperFn of helperFns) { + if (isWithinRange(position, helperFn.ranges.path)) { + let validated = validateConfigPath( + state, + helperFn.path, + helperFn.helper === 'theme' ? ['theme'] : [] + ) + let value = validated.isValid ? stringifyConfigValue(validated.value) : null + if (value === null) { + return null + } + return { + contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') }, + range: helperFn.ranges.path, + } + } } - const value = validateConfigPath(state, key).isValid - ? stringifyConfigValue(dlv(state.config, key)) - : null - - if (value === null) return null - - return { - contents: { kind: 'markdown', value: ['```plaintext', value, '```'].join('\n') }, - range: { - start: { line: position.line, character: startChar }, - end: { - line: position.line, - character: endChar, - }, - }, - } + return null } async function provideClassNameHover( diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 4e85115..edcb595 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -359,36 +359,48 @@ export function findHelperFunctionsInRange( range?: Range ): DocumentHelperFunction[] { const text = getTextWithoutComments(doc, 'css', range) - const matches = findAll( - /(?^|\s)(?theme|config)\((?:(?')([^']+)'|(?")([^"]+)")[^)]*\)/gm, + let matches = findAll( + /(?\s|^)(?config|theme)(?\(\s*)(?[^)]*?)\s*\)/g, text ) return matches.map((match) => { - let value = match[4] || match[6] - let startIndex = match.index + match.groups.before.length + let quotesBefore = '' + 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 { - full: match[0].substr(match.groups.before.length), - value, helper: match.groups.helper === 'theme' ? 'theme' : 'config', - quotes: match.groups.single ? "'" : '"', - range: resolveRange( - { - start: indexToPosition(text, startIndex), - end: indexToPosition(text, match.index + match[0].length), - }, - range - ), - valueRange: resolveRange( - { - start: indexToPosition(text, startIndex + match.groups.helper.length + 1), - end: indexToPosition( - text, - startIndex + match.groups.helper.length + 1 + 1 + value.length + 1 - ), - }, - range - ), + path, + ranges: { + full: resolveRange( + { + start: indexToPosition(text, startIndex), + end: indexToPosition(text, startIndex + match.groups.path.length), + }, + range + ), + path: resolveRange( + { + start: indexToPosition(text, startIndex + quotesBefore.length), + end: indexToPosition(text, startIndex + quotesBefore.length + path.length), + }, + range + ), + }, } }) } diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index d699ffb..3194643 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -124,12 +124,12 @@ export type DocumentClassName = { } export type DocumentHelperFunction = { - full: string helper: 'theme' | 'config' - value: string - quotes: '"' | "'" - range: Range - valueRange: Range + path: string + ranges: { + full: Range + path: Range + } } export type ClassNameMeta = { diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index 1677c1b..4b2bdfb 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -378,7 +378,11 @@ export async function activate(context: ExtensionContext) { async resolveCompletionItem(item, token, next) { let result = await next(item, token) 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 = selections[0].start.character - result.additionalTextEdits[0].range.start.character let prefixLength =