Skip to content

Commit d219f08

Browse files
committed
Improve color detection performance
1 parent 0296221 commit d219f08

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

0 commit comments

Comments
 (0)