Skip to content

Commit e2cfc2c

Browse files
Handle balanced parens when searching for helper functions (#1354)
Fixes #1285 The regex would cause things like this to not work properly: ``` .foo { color: my-config(theme('fontFamily.sans')) } ``` You'd get an error message like this: ``` 'theme('fontFamily.sans' does not exist in your theme config.(invalidConfigPath) ``` Which means: 1. It saw `config` instead of `my-config` 2. It didn't handle nested parens correctly 3. the nested `theme` function wasn't found This PR fixes all three of these
1 parent 888e042 commit e2cfc2c

File tree

3 files changed

+304
-36
lines changed

3 files changed

+304
-36
lines changed

packages/tailwindcss-language-service/src/util/find.test.ts

+171-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { test } from 'vitest'
2-
import { findClassListsInHtmlRange, findClassNameAtPosition } from './find'
3-
import { js, html, pug, createDocument } from './test-utils'
2+
import {
3+
findClassListsInHtmlRange,
4+
findClassNameAtPosition,
5+
findHelperFunctionsInDocument,
6+
} from './find'
7+
import { js, html, pug, createDocument, css } from './test-utils'
8+
import type { Range } from 'vscode-languageserver-textdocument'
9+
10+
const range = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
11+
start: { line: startLine, character: startCol },
12+
end: { line: endLine, character: endCol },
13+
})
414

515
test('class regex works in astro', async ({ expect }) => {
616
let file = createDocument({
@@ -875,3 +885,162 @@ test('Can find class name inside JS/TS functions in <script> tags (Svelte)', asy
875885
},
876886
})
877887
})
888+
889+
test('Can find helper functions in CSS', async ({ expect }) => {
890+
let file = createDocument({
891+
name: 'file.css',
892+
lang: 'css',
893+
settings: {
894+
tailwindCSS: {
895+
classFunctions: ['clsx'],
896+
},
897+
},
898+
content: `
899+
.a { color: theme(foo); }
900+
.a { color: theme(foo, default); }
901+
.a { color: theme("foo"); }
902+
.a { color: theme("foo", default); }
903+
.a { color: theme(foo / 0.5); }
904+
.a { color: theme(foo / 0.5, default); }
905+
.a { color: theme("foo" / 0.5); }
906+
.a { color: theme("foo" / 0.5, default); }
907+
908+
/* nested invocations */
909+
.a { color: from-config(theme(foo)); }
910+
.a { color: from-config(theme(foo, default)); }
911+
.a { color: from-config(theme("foo")); }
912+
.a { color: from-config(theme("foo", default)); }
913+
.a { color: from-config(theme(foo / 0.5)); }
914+
.a { color: from-config(theme(foo / 0.5, default)); }
915+
.a { color: from-config(theme("foo" / 0.5)); }
916+
.a { color: from-config(theme("foo" / 0.5, default)); }
917+
`,
918+
})
919+
920+
let fns = findHelperFunctionsInDocument(file.state, file.doc)
921+
922+
expect(fns).toEqual([
923+
{
924+
helper: 'theme',
925+
path: 'foo',
926+
ranges: { full: range(1, 24, 1, 27), path: range(1, 24, 1, 27) },
927+
},
928+
{
929+
helper: 'theme',
930+
path: 'foo',
931+
ranges: { full: range(2, 24, 2, 36), path: range(2, 24, 2, 27) },
932+
},
933+
{
934+
helper: 'theme',
935+
path: 'foo',
936+
ranges: { full: range(3, 24, 3, 29), path: range(3, 25, 3, 28) },
937+
},
938+
{
939+
helper: 'theme',
940+
path: 'foo',
941+
ranges: { full: range(4, 24, 4, 38), path: range(4, 25, 4, 28) },
942+
},
943+
{
944+
helper: 'theme',
945+
path: 'foo',
946+
ranges: { full: range(5, 24, 5, 33), path: range(5, 24, 5, 27) },
947+
},
948+
{
949+
helper: 'theme',
950+
path: 'foo',
951+
ranges: { full: range(6, 24, 6, 42), path: range(6, 24, 6, 27) },
952+
},
953+
{
954+
helper: 'theme',
955+
path: 'foo',
956+
ranges: { full: range(7, 24, 7, 35), path: range(7, 25, 7, 28) },
957+
},
958+
{
959+
helper: 'theme',
960+
path: 'foo',
961+
ranges: { full: range(8, 24, 8, 44), path: range(8, 25, 8, 28) },
962+
},
963+
964+
// Nested
965+
{
966+
helper: 'config',
967+
path: 'theme(foo)',
968+
ranges: { full: range(11, 30, 11, 40), path: range(11, 30, 11, 40) },
969+
},
970+
{
971+
helper: 'theme',
972+
path: 'foo',
973+
ranges: { full: range(11, 36, 11, 39), path: range(11, 36, 11, 39) },
974+
},
975+
{
976+
helper: 'config',
977+
path: 'theme(foo, default)',
978+
ranges: { full: range(12, 30, 12, 49), path: range(12, 30, 12, 49) },
979+
},
980+
{
981+
helper: 'theme',
982+
path: 'foo',
983+
ranges: { full: range(12, 36, 12, 48), path: range(12, 36, 12, 39) },
984+
},
985+
{
986+
helper: 'config',
987+
path: 'theme("foo")',
988+
ranges: { full: range(13, 30, 13, 42), path: range(13, 30, 13, 42) },
989+
},
990+
{
991+
helper: 'theme',
992+
path: 'foo',
993+
ranges: { full: range(13, 36, 13, 41), path: range(13, 37, 13, 40) },
994+
},
995+
{
996+
helper: 'config',
997+
path: 'theme("foo", default)',
998+
ranges: { full: range(14, 30, 14, 51), path: range(14, 30, 14, 51) },
999+
},
1000+
{
1001+
helper: 'theme',
1002+
path: 'foo',
1003+
ranges: { full: range(14, 36, 14, 50), path: range(14, 37, 14, 40) },
1004+
},
1005+
{
1006+
helper: 'config',
1007+
path: 'theme(foo / 0.5)',
1008+
ranges: { full: range(15, 30, 15, 46), path: range(15, 30, 15, 46) },
1009+
},
1010+
{
1011+
helper: 'theme',
1012+
path: 'foo',
1013+
ranges: { full: range(15, 36, 15, 45), path: range(15, 36, 15, 39) },
1014+
},
1015+
{
1016+
helper: 'config',
1017+
path: 'theme(foo / 0.5, default)',
1018+
ranges: { full: range(16, 30, 16, 55), path: range(16, 30, 16, 55) },
1019+
},
1020+
{
1021+
helper: 'theme',
1022+
path: 'foo',
1023+
ranges: { full: range(16, 36, 16, 54), path: range(16, 36, 16, 39) },
1024+
},
1025+
{
1026+
helper: 'config',
1027+
path: 'theme("foo" / 0.5)',
1028+
ranges: { full: range(17, 30, 17, 48), path: range(17, 30, 17, 48) },
1029+
},
1030+
{
1031+
helper: 'theme',
1032+
path: 'foo',
1033+
ranges: { full: range(17, 36, 17, 47), path: range(17, 37, 17, 40) },
1034+
},
1035+
{
1036+
helper: 'config',
1037+
path: 'theme("foo" / 0.5, default)',
1038+
ranges: { full: range(18, 30, 18, 57), path: range(18, 30, 18, 57) },
1039+
},
1040+
{
1041+
helper: 'theme',
1042+
path: 'foo',
1043+
ranges: { full: range(18, 36, 18, 56), path: range(18, 37, 18, 40) },
1044+
},
1045+
])
1046+
})

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

+132-34
Original file line numberDiff line numberDiff line change
@@ -403,13 +403,12 @@ export function findHelperFunctionsInRange(
403403
doc: TextDocument,
404404
range?: Range,
405405
): DocumentHelperFunction[] {
406-
const text = getTextWithoutComments(doc, 'css', range)
407-
let matches = findAll(
408-
/(?<prefix>[\W])(?<helper>config|theme|--theme|var)(?<innerPrefix>\(\s*)(?<path>[^)]*?)\s*\)/g,
409-
text,
410-
)
406+
let text = getTextWithoutComments(doc, 'css', range)
407+
408+
// Find every instance of a helper function
409+
let matches = findAll(/\b(?<helper>config|theme|--theme|var)\(/g, text)
411410

412-
// Eliminate matches that are on an `@import`
411+
// Eliminate matches that are attached to an `@import`
413412
matches = matches.filter((match) => {
414413
// Scan backwards to see if we're in an `@import` statement
415414
for (let i = match.index - 1; i >= 0; i--) {
@@ -427,58 +426,157 @@ export function findHelperFunctionsInRange(
427426
return true
428427
})
429428

430-
return matches.map((match) => {
431-
let quotesBefore = ''
432-
let path = match.groups.path
433-
let commaIndex = getFirstCommaIndex(path)
434-
if (commaIndex !== null) {
435-
path = path.slice(0, commaIndex).trimEnd()
436-
}
437-
path = path.replace(/['"]+$/, '').replace(/^['"]+/, (m) => {
438-
quotesBefore = m
439-
return ''
440-
})
441-
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/)
442-
if (matches) {
443-
path = matches[1]
429+
let fns: DocumentHelperFunction[] = []
430+
431+
// Collect the first argument of each fn accounting for balanced params
432+
const COMMA = 0x2c
433+
const SLASH = 0x2f
434+
const BACKSLASH = 0x5c
435+
const OPEN_PAREN = 0x28
436+
const CLOSE_PAREN = 0x29
437+
const DOUBLE_QUOTE = 0x22
438+
const SINGLE_QUOTE = 0x27
439+
440+
let len = text.length
441+
442+
for (let match of matches) {
443+
let argsStart = match.index + match[0].length
444+
let argsEnd = null
445+
let pathStart = argsStart
446+
let pathEnd = null
447+
let depth = 1
448+
449+
// Scan until we find a `,` or balanced `)` not in quotes
450+
for (let idx = argsStart; idx < len; ++idx) {
451+
let char = text.charCodeAt(idx)
452+
453+
if (char === BACKSLASH) {
454+
idx += 1
455+
}
456+
457+
//
458+
else if (char === SINGLE_QUOTE || char === DOUBLE_QUOTE) {
459+
while (++idx < len) {
460+
let nextChar = text.charCodeAt(idx)
461+
if (nextChar === BACKSLASH) {
462+
idx += 1
463+
continue
464+
}
465+
if (nextChar === char) break
466+
}
467+
}
468+
469+
//
470+
else if (char === OPEN_PAREN) {
471+
depth += 1
472+
}
473+
474+
//
475+
else if (char === CLOSE_PAREN) {
476+
depth -= 1
477+
478+
if (depth === 0) {
479+
pathEnd ??= idx
480+
argsEnd = idx
481+
break
482+
}
483+
}
484+
485+
//
486+
else if (char === COMMA && depth === 1) {
487+
pathEnd ??= idx
488+
}
444489
}
445-
path = path.replace(/['"]*\s*$/, '')
446490

447-
let startIndex =
448-
match.index +
449-
match.groups.prefix.length +
450-
match.groups.helper.length +
451-
match.groups.innerPrefix.length
491+
if (argsEnd === null) continue
452492

453-
let helper: 'config' | 'theme' | 'var' = 'config'
493+
let helper: 'config' | 'theme' | 'var'
454494

455495
if (match.groups.helper === 'theme' || match.groups.helper === '--theme') {
456496
helper = 'theme'
457497
} else if (match.groups.helper === 'var') {
458498
helper = 'var'
499+
} else if (match.groups.helper === 'config') {
500+
helper = 'config'
501+
} else {
502+
continue
459503
}
460504

461-
return {
505+
let path = text.slice(pathStart, pathEnd)
506+
507+
// Skip leading/trailing whitespace
508+
pathStart += path.match(/^\s+/)?.length ?? 0
509+
pathEnd -= path.match(/\s+$/)?.length ?? 0
510+
511+
// Skip leading/trailing quotes
512+
let quoteStart = path.match(/^['"]+/)?.length ?? 0
513+
let quoteEnd = path.match(/['"]+$/)?.length ?? 0
514+
515+
if (quoteStart && quoteEnd) {
516+
pathStart += quoteStart
517+
pathEnd -= quoteEnd
518+
}
519+
520+
// Clip to the top-level slash
521+
depth = 1
522+
for (let idx = pathStart; idx < pathEnd; ++idx) {
523+
let char = text.charCodeAt(idx)
524+
if (char === BACKSLASH) {
525+
idx += 1
526+
} else if (char === OPEN_PAREN) {
527+
depth += 1
528+
} else if (char === CLOSE_PAREN) {
529+
depth -= 1
530+
} else if (char === SLASH && depth === 1) {
531+
pathEnd = idx
532+
}
533+
}
534+
535+
// Re-slice
536+
path = text.slice(pathStart, pathEnd)
537+
538+
// Skip leading/trailing whitespace
539+
//
540+
// This can happen if we've clipped the path down to before the `/`
541+
pathStart += path.match(/^\s+/)?.length ?? 0
542+
pathEnd -= path.match(/\s+$/)?.length ?? 0
543+
544+
// Re-slice
545+
path = text.slice(pathStart, pathEnd)
546+
547+
// Skip leading/trailing quotes
548+
quoteStart = path.match(/^['"]+/)?.length ?? 0
549+
quoteEnd = path.match(/['"]+$/)?.length ?? 0
550+
551+
pathStart += quoteStart
552+
pathEnd -= quoteEnd
553+
554+
// Re-slice
555+
path = text.slice(pathStart, pathEnd)
556+
557+
fns.push({
462558
helper,
463559
path,
464560
ranges: {
465561
full: absoluteRange(
466562
{
467-
start: indexToPosition(text, startIndex),
468-
end: indexToPosition(text, startIndex + match.groups.path.length),
563+
start: indexToPosition(text, argsStart),
564+
end: indexToPosition(text, argsEnd),
469565
},
470566
range,
471567
),
472568
path: absoluteRange(
473569
{
474-
start: indexToPosition(text, startIndex + quotesBefore.length),
475-
end: indexToPosition(text, startIndex + quotesBefore.length + path.length),
570+
start: indexToPosition(text, pathStart),
571+
end: indexToPosition(text, pathEnd),
476572
},
477573
range,
478574
),
479575
},
480-
}
481-
})
576+
})
577+
}
578+
579+
return fns
482580
}
483581

484582
export function indexToPosition(str: string, index: number): Position {

packages/vscode-tailwindcss/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Calculate swatches for HSL colors with angular units ([#1360](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1360))
1111
- Fix error when using VSCode < 1.78 ([#1353](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1353))
1212
- Don’t skip suggesting empty variant implementations ([#1352](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1352))
13+
- Handle helper function lookups in nested parens ([#1354](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1354))
1314

1415
# 0.14.16
1516

0 commit comments

Comments
 (0)