Skip to content

Commit cb3a7b9

Browse files
committed
Improve color detection performance
1 parent c9086eb commit cb3a7b9

File tree

2 files changed

+307
-16
lines changed

2 files changed

+307
-16
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test, expect } from 'vitest'
2+
import namedColors from 'color-name'
3+
import { findColors } from './color'
4+
5+
let table: string[] = []
6+
7+
// 1. Named colors
8+
table.push(...Object.keys(namedColors))
9+
10+
// We don't show swatches for transparent colors so we don't need to detect it
11+
// table.push('transparent')
12+
13+
// 2. Hex
14+
table.push('#639')
15+
table.push('#0000')
16+
table.push('#7f7f7f')
17+
table.push('#7f7f7f7f')
18+
19+
// 3. Legacy color syntax
20+
for (let fn of ['rgb', 'hsl']) {
21+
table.push(`${fn}(0, 0, 0)`)
22+
table.push(`${fn}(127, 127, 127)`)
23+
24+
table.push(`${fn}a(0, 0, 0, 0)`)
25+
table.push(`${fn}a(127, 127, 127, .5)`)
26+
table.push(`${fn}a(127, 127, 127, 0.5)`)
27+
}
28+
29+
// 4. Modern color syntax
30+
let numeric = ['0', '0.0', '0.3', '1.0', '50%', '1deg', '1grad', '1turn']
31+
let alphas = ['0', '0.0', '0.3', '1.0']
32+
33+
let fields = [...numeric.flatMap((field) => [field, `-${field}`]), 'var(--foo)']
34+
35+
for (let fn of ['rgb', 'hsl', 'lab', 'lch', 'oklab', 'oklch']) {
36+
for (let field of fields) {
37+
table.push(`${fn}(${field} ${field} ${field})`)
38+
39+
for (let alpha of alphas) {
40+
table.push(`${fn}(${field} ${field} ${field} / ${alpha})`)
41+
}
42+
}
43+
}
44+
45+
// https://github.com/khalilgharbaoui/coloregex
46+
const COLOR_REGEX = new RegExp(
47+
`(?<=^|[\\s(,])(#(?:[0-9a-f]{3,4}|[0-9a-f]{6,8})|(?:rgba?|hsla?|(?:ok)?(?:lab|lch))\\(\\s*(?:(?:-?[\\d.]+(?:%|deg|g?rad|turn)?|var\\([^)]+\\))(\\s*[,/]\\s*|\\s+)+){2,3}\\s*(?:-?[\\d.]+(?:%|deg|g?rad|turn)?|var\\([^)]+\\))?\\)|transparent|${Object.keys(
48+
namedColors,
49+
).join('|')})(?=$|[\\s),])`,
50+
'gi',
51+
)
52+
53+
function findColorsRegex(str: string): string[] {
54+
let matches = str.matchAll(COLOR_REGEX)
55+
return Array.from(matches, (match) => match[1])
56+
}
57+
58+
let boundaries = ['', ' ', '(', ',']
59+
60+
test.for(table)('finds color: $0', (color) => {
61+
for (let start of boundaries) {
62+
for (let end of boundaries) {
63+
if (end === '(') end = ')'
64+
65+
expect(findColors(`${start}${color}${end}`)).toEqual([color])
66+
expect(findColorsRegex(`${start}${color}${end}`)).toEqual([color])
67+
}
68+
}
69+
70+
expect(findColors(`var(--foo, ${color})`)).toEqual([color])
71+
expect(findColorsRegex(`var(--foo, ${color})`)).toEqual([color])
72+
})
73+
74+
test('invalid named', () => {
75+
expect(findColors(`blackz`)).toEqual([])
76+
expect(findColorsRegex(`blackz`)).toEqual([])
77+
})
78+
79+
test('invalid hex', () => {
80+
expect(findColors(`#7f7f7fz`)).toEqual([])
81+
expect(findColorsRegex(`#7f7f7fz`)).toEqual([])
82+
})

packages/tailwindcss-language-service/src/util/color.ts

Lines changed: 225 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,29 +49,14 @@ function getKeywordColor(value: unknown): KeywordColor | null {
4949
return null
5050
}
5151

52-
// https://github.com/khalilgharbaoui/coloregex
53-
const colorRegex = new RegExp(
54-
`(?:^|\\s|\\(|,)(#(?:[0-9a-f]{2}){2,4}|(#[0-9a-f]{3})|(rgba?|hsla?|(?:ok)?(?:lab|lch))\\(\\s*(-?[\\d.]+(%|deg|rad|grad|turn)?(\\s*[,/]\\s*|\\s+)+){2,3}\\s*([\\d.]+(%|deg|rad|grad|turn)?|var\\([^)]+\\))?\\)|transparent|currentColor|${Object.keys(
55-
namedColors,
56-
).join('|')})(?:$|\\s|\\)|,)`,
57-
'gi',
58-
)
59-
6052
function getColorsInString(state: State, str: string): ParsedColor[] {
6153
if (/(?:box|drop)-shadow/.test(str) && !/--tw-drop-shadow/.test(str)) return []
6254

63-
function toColor(match: RegExpMatchArray) {
64-
let color = match[1].replace(/var\([^)]+\)/, '1')
65-
return getKeywordColor(color) ?? tryParseColor(color)
66-
}
67-
6855
str = replaceCssVarsWithFallbacks(state, str)
6956
str = removeColorMixWherePossible(str)
7057
str = resolveLightDark(str)
7158

72-
let possibleColors = str.matchAll(colorRegex)
73-
74-
return Array.from(possibleColors, toColor).filter(Boolean)
59+
return parseColors(str)
7560
}
7661

7762
function getColorFromDecls(
@@ -335,3 +320,227 @@ const LIGHT_DARK_REGEX = /light-dark\(\s*(.*?)\s*,\s*.*?\s*\)/g
335320
function resolveLightDark(str: string) {
336321
return str.replace(LIGHT_DARK_REGEX, (_, lightColor) => lightColor)
337322
}
323+
324+
const COLOR_FNS = new Set([
325+
//
326+
'rgb',
327+
'rgba',
328+
'hwb',
329+
'hsl',
330+
'hsla',
331+
'lab',
332+
'lch',
333+
'oklab',
334+
'oklch',
335+
'color',
336+
])
337+
338+
const COLOR_NAMES = new Set([
339+
...Object.keys(namedColors).map((c) => c.toLowerCase()),
340+
'transparent',
341+
'currentcolor',
342+
])
343+
344+
const CSS_VARS = /var\([^)]+\)/
345+
const COLOR_FN_ARGS =
346+
/^\s*(?:(?:-?[\d.]+(?:%|deg|g?rad|turn)?|var\([^)]+\))(?:\s*[,/]\s*|\s+)){2,3}(?:-?[\d.]+(?:%|deg|g?rad|turn)?|var\([^)]+\))\s*$/i
347+
348+
const POUND = 0x23
349+
const ZERO = 0x30
350+
const NINE = 0x39
351+
const DOUBLE_QUOTE = 0x22
352+
const SINGLE_QUOTE = 0x27
353+
const BACKSLASH = 0x5c
354+
const LOWER_A = 0x61
355+
const LOWER_F = 0x66
356+
const LOWER_Z = 0x7a
357+
const L_PAREN = 0x28
358+
const R_PAREN = 0x29
359+
const SPACE = 0x20
360+
const COMMA = 0x2c
361+
const DASH = 0x2d
362+
const LINE_BREAK = 0x0a
363+
const CARRIAGE_RETURN = 0xd
364+
const TAB = 0x09
365+
366+
type Span = [start: number, end: number]
367+
368+
function maybeFindColors(input: string): Span[] {
369+
let colors: Span[] = []
370+
let len = input.length
371+
372+
for (let i = 0; i < len; ++i) {
373+
let char = input.charCodeAt(i)
374+
let inner = char
375+
376+
if (char >= LOWER_A && char <= LOWER_Z) {
377+
// Read until we don't have a named color character
378+
let start = i
379+
let end = i
380+
381+
for (let j = start + 1; j < len; j++) {
382+
inner = input.charCodeAt(j)
383+
384+
if (inner >= ZERO && inner <= NINE) {
385+
end = j // 0-9
386+
} else if (inner >= LOWER_A && inner <= LOWER_Z) {
387+
end = j // a-z
388+
} else if (inner === DASH) {
389+
end = j // -
390+
} else if (inner === L_PAREN) {
391+
// Start of a function
392+
break
393+
} else if (
394+
inner === COMMA ||
395+
inner === SPACE ||
396+
inner === LINE_BREAK ||
397+
inner === TAB ||
398+
inner === CARRIAGE_RETURN ||
399+
inner === R_PAREN
400+
) {
401+
// (?=$|[\\s),])
402+
break
403+
} else {
404+
end = i
405+
break
406+
}
407+
}
408+
409+
let name = input.slice(start, end + 1)
410+
411+
if (COLOR_NAMES.has(name)) {
412+
i = end
413+
colors.push([start, end + 1])
414+
continue
415+
}
416+
417+
if (inner === L_PAREN && COLOR_FNS.has(name)) {
418+
// Scan until the next balanced R_PAREN
419+
let depth = 1
420+
let argStart = end + 2
421+
422+
for (let j = argStart; j < len; ++j) {
423+
inner = input.charCodeAt(j)
424+
425+
// The next character is escaped, so we skip it.
426+
if (inner === BACKSLASH) {
427+
j += 1
428+
}
429+
430+
// Strings should be handled as-is until the end of the string. No need to
431+
// worry about balancing parens, brackets, or curlies inside a string.
432+
else if (inner === SINGLE_QUOTE || inner === DOUBLE_QUOTE) {
433+
// Ensure we don't go out of bounds.
434+
while (++j < len) {
435+
let nextChar = input.charCodeAt(j)
436+
437+
// The next character is escaped, so we skip it.
438+
if (nextChar === BACKSLASH) {
439+
j += 1
440+
continue
441+
}
442+
443+
if (nextChar === char) {
444+
break
445+
}
446+
}
447+
}
448+
449+
// Track opening parens
450+
else if (inner === L_PAREN) {
451+
depth++
452+
}
453+
454+
// Track closing parens
455+
else if (inner === R_PAREN) {
456+
depth--
457+
}
458+
459+
if (depth > 0) continue
460+
461+
let args = input.slice(argStart, j)
462+
463+
if (!COLOR_FN_ARGS.test(args)) continue
464+
colors.push([start, j + 1])
465+
i = j + 1
466+
467+
break
468+
}
469+
470+
continue
471+
}
472+
473+
i = end
474+
}
475+
476+
//
477+
else if (char === POUND) {
478+
// Read until we don't have a named color character
479+
let start = i
480+
let end = i
481+
482+
// i + 1 = first hex digit
483+
// i + 1 + 8 = one past the last hex digit
484+
let last = Math.min(start + 1 + 8, len)
485+
486+
for (let j = start + 1; j < last; j++) {
487+
let inner = input.charCodeAt(j)
488+
489+
if (inner >= ZERO && inner <= NINE) {
490+
end = j // 0-9
491+
} else if (inner >= LOWER_A && inner <= LOWER_F) {
492+
end = j // a-f
493+
} else if (
494+
inner === COMMA ||
495+
inner === SPACE ||
496+
inner === TAB ||
497+
inner === LINE_BREAK ||
498+
inner === CARRIAGE_RETURN ||
499+
inner === R_PAREN
500+
) {
501+
// (?=$|[\\s),])
502+
break
503+
} else {
504+
end = start
505+
break
506+
}
507+
}
508+
509+
let hexLen = end - start
510+
i = end
511+
512+
if (hexLen === 3 || hexLen === 4 || hexLen === 6 || hexLen === 8) {
513+
colors.push([start, end + 1])
514+
continue
515+
}
516+
}
517+
}
518+
519+
return colors
520+
}
521+
522+
export function findColors(input: string): string[] {
523+
return maybeFindColors(input.toLowerCase()).map(([start, end]) => input.slice(start, end))
524+
}
525+
526+
export function parseColors(input: string): ParsedColor[] {
527+
let colors: ParsedColor[] = []
528+
529+
for (let str of findColors(input)) {
530+
str = str.replace(CSS_VARS, '1')
531+
532+
let keyword = getKeywordColor(str)
533+
if (keyword) {
534+
colors.push(keyword)
535+
continue
536+
}
537+
538+
let color = tryParseColor(str)
539+
if (color) {
540+
colors.push(color)
541+
continue
542+
}
543+
}
544+
545+
return colors
546+
}

0 commit comments

Comments
 (0)