From 4b46fc5c518189825b8e1d0342f7ddefaf2bf5b8 Mon Sep 17 00:00:00 2001 From: Florian Stellbrink Date: Mon, 11 Mar 2024 18:17:45 +0100 Subject: [PATCH] Add color format options (rgb, hex) (#831) * Add color format options (rgb, hex) Formats are displayed in completions and as comments in hovered css. This helps with comparing code to designs. * Start separate process for each withFixture block This will give us a guaranteed way to isolate global state in tests * Add tests * Remove `colorFormat` setting wip * Always call `addColorEquivalentsToCss` for Tailwind v2 and below * Generate hex for RGB and HSL colors * Refactor * Refactor * Refactor * Update tests * Update for v4 * Use stringify methods * Update tests --------- Co-authored-by: Jordan Pittman --- .../tests/completions/completions.test.js | 179 ++++++++++++++---- .../tests/env/multi-config-content.test.js | 4 +- .../tests/env/multi-config.test.js | 4 +- .../tests/hover/hover.test.js | 2 +- .../src/completionProvider.ts | 24 +-- .../src/util/color.ts | 10 +- .../src/util/colorEquivalents.ts | 51 +++++ .../src/util/comments.ts | 14 ++ .../src/util/equivalents.ts | 29 +++ .../src/util/jit.ts | 7 +- .../src/util/pixelEquivalents.ts | 37 +--- .../src/util/stringify.ts | 6 +- 12 files changed, 272 insertions(+), 95 deletions(-) create mode 100644 packages/tailwindcss-language-service/src/util/colorEquivalents.ts create mode 100644 packages/tailwindcss-language-service/src/util/comments.ts create mode 100644 packages/tailwindcss-language-service/src/util/equivalents.ts diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js index d181f4a3..0429d396 100644 --- a/packages/tailwindcss-language-server/tests/completions/completions.test.js +++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js @@ -1,8 +1,8 @@ import { test } from 'vitest' import { withFixture } from '../common' -withFixture('basic', (c) => { - async function completion({ +function buildCompletion(c) { + return async function completion({ lang, text, position, @@ -19,6 +19,10 @@ withFixture('basic', (c) => { context, }) } +} + +withFixture('basic', (c) => { + let completion = buildCompletion(c) async function expectCompletions({ expect, lang, text, position, settings }) { let result = await completion({ lang, text, position, settings }) @@ -148,7 +152,7 @@ withFixture('basic', (c) => { expect(result).toBe(null) }) - test('classRegex matching empty string', async ({ expect }) => { + test.concurrent('classRegex matching empty string', async ({ expect }) => { try { let result = await completion({ text: "let _ = ''", @@ -203,24 +207,87 @@ withFixture('basic', (c) => { }) }) -withFixture('overrides-variants', (c) => { - async function completion({ - lang, - text, - position, - context = { - triggerKind: 1, - }, - settings, - }) { - let textDocument = await c.openDocument({ text, lang, settings }) +withFixture('basic', (c) => { + let completion = buildCompletion(c) - return c.sendRequest('textDocument/completion', { - textDocument, - position, - context, + test('Completions have default pixel equivalents (1rem == 16px)', async ({ expect }) => { + let result = await completion({ + lang: 'html', + text: '
', + position: { line: 0, character: 12 }, }) - } + + let item = result.items.find((item) => item.label === 'text-sm') + let resolved = await c.sendRequest('completionItem/resolve', item) + + expect(resolved).toEqual({ + ...item, + detail: 'font-size: 0.875rem/* 14px */; line-height: 1.25rem/* 20px */;', + documentation: { + kind: 'markdown', + value: + '```css\n.text-sm {\n font-size: 0.875rem/* 14px */;\n line-height: 1.25rem/* 20px */;\n}\n```', + }, + }) + }) +}) + +withFixture('basic', (c) => { + let completion = buildCompletion(c) + + test('Completions have customizable pixel equivalents (1rem == 10px)', async ({ expect }) => { + await c.updateSettings({ + tailwindCSS: { + rootFontSize: 10, + }, + }) + + let result = await completion({ + lang: 'html', + text: '
', + position: { line: 0, character: 12 }, + }) + + let item = result.items.find((item) => item.label === 'text-sm') + + let resolved = await c.sendRequest('completionItem/resolve', item) + + expect(resolved).toEqual({ + ...item, + detail: 'font-size: 0.875rem/* 8.75px */; line-height: 1.25rem/* 12.5px */;', + documentation: { + kind: 'markdown', + value: + '```css\n.text-sm {\n font-size: 0.875rem/* 8.75px */;\n line-height: 1.25rem/* 12.5px */;\n}\n```', + }, + }) + }) +}) + +withFixture('basic', (c) => { + let completion = buildCompletion(c) + + test('Completions have color equivalents presented as hex', async ({ expect }) => { + let result = await completion({ + lang: 'html', + text: '
', + position: { line: 0, character: 12 }, + }) + + let item = result.items.find((item) => item.label === 'bg-red-500') + + let resolved = await c.sendRequest('completionItem/resolve', item) + + expect(resolved).toEqual({ + ...item, + detail: '--tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity));', + documentation: '#ef4444', + }) + }) +}) + +withFixture('overrides-variants', (c) => { + let completion = buildCompletion(c) test.concurrent( 'duplicate variant + value pairs do not produce multiple completions', @@ -236,23 +303,7 @@ withFixture('overrides-variants', (c) => { }) withFixture('v4/basic', (c) => { - async function completion({ - lang, - text, - position, - context = { - triggerKind: 1, - }, - settings, - }) { - let textDocument = await c.openDocument({ text, lang, settings }) - - return c.sendRequest('textDocument/completion', { - textDocument, - position, - context, - }) - } + let completion = buildCompletion(c) async function expectCompletions({ expect, lang, text, position, settings }) { let result = await completion({ lang, text, position, settings }) @@ -439,7 +490,7 @@ withFixture('v4/basic', (c) => { expect(resolved).toEqual({ ...item, - detail: 'text-transform: uppercase', + detail: 'text-transform: uppercase;', documentation: { kind: 'markdown', value: '```css\n.uppercase {\n text-transform: uppercase;\n}\n```', @@ -447,3 +498,57 @@ withFixture('v4/basic', (c) => { }) }) }) + +withFixture('v4/basic', (c) => { + let completion = buildCompletion(c) + + test('Completions have customizable pixel equivalents (1rem == 10px)', async ({ expect }) => { + await c.updateSettings({ + tailwindCSS: { + rootFontSize: 10, + }, + }) + + let result = await completion({ + lang: 'html', + text: '
', + position: { line: 0, character: 12 }, + }) + + let item = result.items.find((item) => item.label === 'text-sm') + + let resolved = await c.sendRequest('completionItem/resolve', item) + + expect(resolved).toEqual({ + ...item, + detail: 'font-size: 0.875rem/* 8.75px */; line-height: 1.25rem/* 12.5px */;', + documentation: { + kind: 'markdown', + value: + '```css\n.text-sm {\n font-size: 0.875rem/* 8.75px */;\n line-height: 1.25rem/* 12.5px */;\n}\n```', + }, + }) + }) +}) + +withFixture('v4/basic', (c) => { + let completion = buildCompletion(c) + + test('Completions have color equivalents presented as hex', async ({ expect }) => { + let result = await completion({ + lang: 'html', + text: '
', + position: { line: 0, character: 12 }, + }) + + let item = result.items.find((item) => item.label === 'bg-red-500') + + let resolved = await c.sendRequest('completionItem/resolve', item) + + expect(resolved).toEqual({ + ...item, + detail: 'background-color: #ef4444;', + documentation: '#ef4444', + }) + }) +}) diff --git a/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js index 1e594480..591a3325 100644 --- a/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js +++ b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js @@ -13,7 +13,7 @@ withFixture('multi-config-content', (c) => { contents: { language: 'css', value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity));\n}', + '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity))/* #ff0000 */;\n}', }, range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, }) @@ -30,7 +30,7 @@ withFixture('multi-config-content', (c) => { contents: { language: 'css', value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity));\n}', + '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity))/* #0000ff */;\n}', }, range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, }) diff --git a/packages/tailwindcss-language-server/tests/env/multi-config.test.js b/packages/tailwindcss-language-server/tests/env/multi-config.test.js index 7e218f36..9d34eb62 100644 --- a/packages/tailwindcss-language-server/tests/env/multi-config.test.js +++ b/packages/tailwindcss-language-server/tests/env/multi-config.test.js @@ -13,7 +13,7 @@ withFixture('multi-config', (c) => { contents: { language: 'css', value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity));\n}', + '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity))/* #ff0000 */;\n}', }, range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, }) @@ -30,7 +30,7 @@ withFixture('multi-config', (c) => { contents: { language: 'css', value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity));\n}', + '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity))/* #0000ff */;\n}', }, range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, }) diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js index 2b23f8e9..7775b9de 100644 --- a/packages/tailwindcss-language-server/tests/hover/hover.test.js +++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js @@ -38,7 +38,7 @@ withFixture('basic', (c) => { expected: '.bg-red-500 {\n' + ' --tw-bg-opacity: 1;\n' + - ' background-color: rgb(239 68 68 / var(--tw-bg-opacity));\n' + + ' background-color: rgb(239 68 68 / var(--tw-bg-opacity))/* #ef4444 */;\n' + '}', expectedRange: { start: { line: 0, character: 12 }, diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 05fa9bf2..be70686e 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -12,7 +12,7 @@ import { import type { TextDocument } from 'vscode-languageserver-textdocument' import dlv from 'dlv' import removeMeta from './util/removeMeta' -import { getColor, getColorFromValue } from './util/color' +import { formatColor, getColor, getColorFromValue } from './util/color' import { isHtmlContext } from './util/html' import { isCssContext } from './util/css' import { findLast, matchClassAttributes } from './util/find' @@ -33,7 +33,6 @@ import { validateApply } from './util/validateApply' import { flagEnabled } from './util/flagEnabled' import * as jit from './util/jit' import { getVariantsFromClassName } from './util/getVariantsFromClassName' -import * as culori from 'culori' import { addPixelEquivalentsToMediaQuery, addPixelEquivalentsToValue, @@ -102,9 +101,10 @@ export function completionsFromClassList( if (color !== null) { kind = CompletionItemKind.Color if (typeof color !== 'string' && (color.alpha ?? 1) !== 0) { - documentation = culori.formatRgb(color) + documentation = formatColor(color) } } + return { label: className, ...(documentation ? { documentation } : {}), @@ -298,7 +298,7 @@ export function completionsFromClassList( let documentation: string | undefined if (color && typeof color !== 'string') { - documentation = culori.formatRgb(color) + documentation = formatColor(color) } items.push({ @@ -367,7 +367,7 @@ export function completionsFromClassList( if (color !== null) { kind = CompletionItemKind.Color if (typeof color !== 'string' && (color.alpha ?? 1) !== 0) { - documentation = culori.formatRgb(color) + documentation = formatColor(color) } } @@ -528,7 +528,7 @@ export function completionsFromClassList( let documentation: string | undefined if (color && typeof color !== 'string') { - documentation = culori.formatRgb(color) + documentation = formatColor(color) } items.push({ @@ -575,7 +575,7 @@ export function completionsFromClassList( if (color !== null) { kind = CompletionItemKind.Color if (typeof color !== 'string' && (color.alpha ?? 1) !== 0) { - documentation = culori.formatRgb(color) + documentation = formatColor(color) } } @@ -661,7 +661,7 @@ export function completionsFromClassList( if (color !== null) { kind = CompletionItemKind.Color if (typeof color !== 'string' && (color.alpha ?? 1) !== 0) { - documentation = culori.formatRgb(color) + documentation = formatColor(color) } } @@ -1050,7 +1050,7 @@ function provideCssHelperCompletions( // VS Code bug causes some values to not display in some cases detail: detail === '0' || detail === 'transparent' ? `${detail} ` : detail, ...(color && typeof color !== 'string' && (color.alpha ?? 1) !== 0 - ? { documentation: culori.formatRgb(color) } + ? { documentation: formatColor(color) } : {}), ...(insertClosingBrace ? { textEditText: `${item}]` } : {}), additionalTextEdits: replaceDot @@ -1727,7 +1727,9 @@ export async function resolveCompletionItem( decls.push(node) }) - item.detail = state.designSystem.toCss(decls) + item.detail = await jit.stringifyDecls(state, postcss.rule({ + nodes: decls, + })) } else { item.detail = `${rules.length} rules` } @@ -1736,7 +1738,7 @@ export async function resolveCompletionItem( if (!item.documentation) { item.documentation = { kind: 'markdown' as typeof MarkupKind.Markdown, - value: ['```css', state.designSystem.toCss(rules), '```'].join('\n'), + value: ['```css', await jit.stringifyRoot(state, postcss.root({ nodes: rules })), '```'].join('\n'), } } diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index 86297030..87f9183b 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -1,7 +1,7 @@ import dlv from 'dlv' import { State } from './state' import removeMeta from './removeMeta' -import { ensureArray, dedupe, flatten } from './array' +import { ensureArray, dedupe } from './array' import type { Color } from 'vscode-languageserver' import { getClassNameParts } from './getClassNameAtPosition' import * as jit from './jit' @@ -229,3 +229,11 @@ export function culoriColorToVscodeColor(color: culori.Color): Color { let rgb = toRgb(color) return { red: rgb.r, green: rgb.g, blue: rgb.b, alpha: rgb.alpha ?? 1 } } + +export function formatColor(color: culori.Color): string { + if (color.alpha === undefined || color.alpha === 1) { + return culori.formatHex(color) + } + + return culori.formatHex8(color) +} diff --git a/packages/tailwindcss-language-service/src/util/colorEquivalents.ts b/packages/tailwindcss-language-service/src/util/colorEquivalents.ts new file mode 100644 index 00000000..3a6f8b78 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/colorEquivalents.ts @@ -0,0 +1,51 @@ +import type { Plugin } from 'postcss' +import parseValue from 'postcss-value-parser' +import { formatColor, getColorFromValue } from './color' +import type { Comment } from './comments' + +export function equivalentColorValues({ comments }: { comments: Comment[] }): Plugin { + return { + postcssPlugin: 'plugin', + Declaration(decl) { + if (!decl.value.includes('rgb') && !decl.value.includes('hsl')) { + return + } + + parseValue(decl.value).walk((node) => { + if (node.type !== 'function') { + return true + } + + if ( + node.value !== 'rgb' && + node.value !== 'rgba' && + node.value !== 'hsl' && + node.value !== 'hsla' + ) { + return false + } + + const values = node.nodes.filter((n) => n.type === 'word').map((n) => n.value) + if (values.length < 3) { + return false + } + + const color = getColorFromValue(`rgb(${values.join(', ')})`) + if (!color || typeof color === 'string') { + return false + } + + comments.push({ + index: + decl.source.start.offset + + `${decl.prop}${decl.raws.between}`.length + + node.sourceEndIndex, + value: formatColor(color), + }) + + return false + }) + }, + } +} +equivalentColorValues.postcss = true diff --git a/packages/tailwindcss-language-service/src/util/comments.ts b/packages/tailwindcss-language-service/src/util/comments.ts new file mode 100644 index 00000000..fef04267 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/comments.ts @@ -0,0 +1,14 @@ +export type Comment = { index: number; value: string } + +export function applyComments(str: string, comments: Comment[]): string { + let offset = 0 + + for (let comment of comments) { + let index = comment.index + offset + let commentStr = `/* ${comment.value} */` + str = str.slice(0, index) + commentStr + str.slice(index) + offset += commentStr.length + } + + return str +} diff --git a/packages/tailwindcss-language-service/src/util/equivalents.ts b/packages/tailwindcss-language-service/src/util/equivalents.ts new file mode 100644 index 00000000..d8fb3165 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/equivalents.ts @@ -0,0 +1,29 @@ +import type { TailwindCssSettings } from './state' +import { equivalentPixelValues } from './pixelEquivalents' +import { equivalentColorValues } from './colorEquivalents' +import postcss, { AcceptedPlugin } from 'postcss' +import { applyComments, type Comment } from './comments' + +export function addEquivalents(css: string, settings: TailwindCssSettings): string { + let comments: Comment[] = [] + + let plugins: AcceptedPlugin[] = [] + + if (settings.showPixelEquivalents) { + plugins.push(equivalentPixelValues({ + comments, + rootFontSize: settings.rootFontSize, + })) + } + + plugins.push(equivalentColorValues({ comments })) + + try { + postcss(plugins).process(css, { from: undefined }) + .css + } catch { + return css + } + + return applyComments(css, comments) +} diff --git a/packages/tailwindcss-language-service/src/util/jit.ts b/packages/tailwindcss-language-service/src/util/jit.ts index 5ea815db..a2134807 100644 --- a/packages/tailwindcss-language-service/src/util/jit.ts +++ b/packages/tailwindcss-language-service/src/util/jit.ts @@ -1,6 +1,7 @@ import { State } from './state' import type { Container, Document, Root, Rule, Node, AtRule } from 'postcss' -import { addPixelEquivalentsToCss, addPixelEquivalentsToValue } from './pixelEquivalents' +import { addPixelEquivalentsToValue } from './pixelEquivalents' +import { addEquivalents } from './equivalents' export function bigSign(bigIntValue) { // @ts-ignore @@ -43,9 +44,7 @@ export async function stringifyRoot(state: State, root: Root, uri?: string): Pro let css = clone.toString() - if (settings.tailwindCSS.showPixelEquivalents) { - css = addPixelEquivalentsToCss(css, settings.tailwindCSS.rootFontSize) - } + css = addEquivalents(css, settings.tailwindCSS) let identSize = state.v4 ? 2 : 4 let identPattern = state.v4 ? /^(?: )+/gm : /^(?: )+/gm diff --git a/packages/tailwindcss-language-service/src/util/pixelEquivalents.ts b/packages/tailwindcss-language-service/src/util/pixelEquivalents.ts index 2de7f303..1fd86e56 100644 --- a/packages/tailwindcss-language-service/src/util/pixelEquivalents.ts +++ b/packages/tailwindcss-language-service/src/util/pixelEquivalents.ts @@ -3,8 +3,8 @@ import parseValue from 'postcss-value-parser' import { parse as parseMediaQueryList } from '@csstools/media-query-list-parser' import postcss from 'postcss' import { isTokenNode } from '@csstools/css-parser-algorithms' - -type Comment = { index: number; value: string } +import type { Comment } from './comments' +import { applyComments } from './comments' export function addPixelEquivalentsToValue(value: string, rootFontSize: number): string { if (!value.includes('rem')) { @@ -30,35 +30,6 @@ export function addPixelEquivalentsToValue(value: string, rootFontSize: number): return value } -export function addPixelEquivalentsToCss(css: string, rootFontSize: number): string { - if (!css.includes('em')) { - return css - } - - let comments: Comment[] = [] - - try { - postcss([postcssPlugin({ comments, rootFontSize })]).process(css, { from: undefined }).css - } catch { - return css - } - - return applyComments(css, comments) -} - -function applyComments(str: string, comments: Comment[]): string { - let offset = 0 - - for (let comment of comments) { - let index = comment.index + offset - let commentStr = `/* ${comment.value} */` - str = str.slice(0, index) + commentStr + str.slice(index) - offset += commentStr.length - } - - return str -} - function getPixelEquivalentsForMediaQuery(params: string, rootFontSize: number): Comment[] { let comments: Comment[] = [] @@ -91,7 +62,7 @@ export function addPixelEquivalentsToMediaQuery(query: string, rootFontSize: num }) } -function postcssPlugin({ +export function equivalentPixelValues({ comments, rootFontSize, }: { @@ -144,4 +115,4 @@ function postcssPlugin({ }, } } -postcssPlugin.postcss = true +equivalentPixelValues.postcss = true diff --git a/packages/tailwindcss-language-service/src/util/stringify.ts b/packages/tailwindcss-language-service/src/util/stringify.ts index ab132165..c598731e 100644 --- a/packages/tailwindcss-language-service/src/util/stringify.ts +++ b/packages/tailwindcss-language-service/src/util/stringify.ts @@ -5,7 +5,7 @@ import { ensureArray } from './array' import stringifyObject from 'stringify-object' import isObject from './isObject' import { Settings } from './state' -import { addPixelEquivalentsToCss } from './pixelEquivalents' +import { addEquivalents } from './equivalents' export function stringifyConfigValue(x: any): string { if (isObject(x)) return `${Object.keys(x).length} values` @@ -55,9 +55,7 @@ export function stringifyCss(className: string, obj: any, settings: Settings): s css += `${indent.repeat(i)}\n}` } - if (settings.tailwindCSS.showPixelEquivalents) { - return addPixelEquivalentsToCss(css, settings.tailwindCSS.rootFontSize) - } + css = addEquivalents(css, settings.tailwindCSS) return css }