Skip to content

Handle balanced parens when searching for helper functions #1354

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 171 additions & 2 deletions packages/tailwindcss-language-service/src/util/find.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { test } from 'vitest'
import { findClassListsInHtmlRange, findClassNameAtPosition } from './find'
import { js, html, pug, createDocument } from './test-utils'
import {
findClassListsInHtmlRange,
findClassNameAtPosition,
findHelperFunctionsInDocument,
} from './find'
import { js, html, pug, createDocument, css } from './test-utils'
import type { Range } from 'vscode-languageserver-textdocument'

const range = (startLine: number, startCol: number, endLine: number, endCol: number): Range => ({
start: { line: startLine, character: startCol },
end: { line: endLine, character: endCol },
})

test('class regex works in astro', async ({ expect }) => {
let file = createDocument({
Expand Down Expand Up @@ -875,3 +885,162 @@ test('Can find class name inside JS/TS functions in <script> tags (Svelte)', asy
},
})
})

test('Can find helper functions in CSS', async ({ expect }) => {
let file = createDocument({
name: 'file.css',
lang: 'css',
settings: {
tailwindCSS: {
classFunctions: ['clsx'],
},
},
content: `
.a { color: theme(foo); }
.a { color: theme(foo, default); }
.a { color: theme("foo"); }
.a { color: theme("foo", default); }
.a { color: theme(foo / 0.5); }
.a { color: theme(foo / 0.5, default); }
.a { color: theme("foo" / 0.5); }
.a { color: theme("foo" / 0.5, default); }

/* nested invocations */
.a { color: from-config(theme(foo)); }
.a { color: from-config(theme(foo, default)); }
.a { color: from-config(theme("foo")); }
.a { color: from-config(theme("foo", default)); }
.a { color: from-config(theme(foo / 0.5)); }
.a { color: from-config(theme(foo / 0.5, default)); }
.a { color: from-config(theme("foo" / 0.5)); }
.a { color: from-config(theme("foo" / 0.5, default)); }
`,
})

let fns = findHelperFunctionsInDocument(file.state, file.doc)

expect(fns).toEqual([
{
helper: 'theme',
path: 'foo',
ranges: { full: range(1, 24, 1, 27), path: range(1, 24, 1, 27) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(2, 24, 2, 36), path: range(2, 24, 2, 27) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(3, 24, 3, 29), path: range(3, 25, 3, 28) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(4, 24, 4, 38), path: range(4, 25, 4, 28) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(5, 24, 5, 33), path: range(5, 24, 5, 27) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(6, 24, 6, 42), path: range(6, 24, 6, 27) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(7, 24, 7, 35), path: range(7, 25, 7, 28) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(8, 24, 8, 44), path: range(8, 25, 8, 28) },
},

// Nested
{
helper: 'config',
path: 'theme(foo)',
ranges: { full: range(11, 30, 11, 40), path: range(11, 30, 11, 40) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(11, 36, 11, 39), path: range(11, 36, 11, 39) },
},
{
helper: 'config',
path: 'theme(foo, default)',
ranges: { full: range(12, 30, 12, 49), path: range(12, 30, 12, 49) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(12, 36, 12, 48), path: range(12, 36, 12, 39) },
},
{
helper: 'config',
path: 'theme("foo")',
ranges: { full: range(13, 30, 13, 42), path: range(13, 30, 13, 42) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(13, 36, 13, 41), path: range(13, 37, 13, 40) },
},
{
helper: 'config',
path: 'theme("foo", default)',
ranges: { full: range(14, 30, 14, 51), path: range(14, 30, 14, 51) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(14, 36, 14, 50), path: range(14, 37, 14, 40) },
},
{
helper: 'config',
path: 'theme(foo / 0.5)',
ranges: { full: range(15, 30, 15, 46), path: range(15, 30, 15, 46) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(15, 36, 15, 45), path: range(15, 36, 15, 39) },
},
{
helper: 'config',
path: 'theme(foo / 0.5, default)',
ranges: { full: range(16, 30, 16, 55), path: range(16, 30, 16, 55) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(16, 36, 16, 54), path: range(16, 36, 16, 39) },
},
{
helper: 'config',
path: 'theme("foo" / 0.5)',
ranges: { full: range(17, 30, 17, 48), path: range(17, 30, 17, 48) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(17, 36, 17, 47), path: range(17, 37, 17, 40) },
},
{
helper: 'config',
path: 'theme("foo" / 0.5, default)',
ranges: { full: range(18, 30, 18, 57), path: range(18, 30, 18, 57) },
},
{
helper: 'theme',
path: 'foo',
ranges: { full: range(18, 36, 18, 56), path: range(18, 37, 18, 40) },
},
])
})
166 changes: 132 additions & 34 deletions packages/tailwindcss-language-service/src/util/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,13 +403,12 @@ export function findHelperFunctionsInRange(
doc: TextDocument,
range?: Range,
): DocumentHelperFunction[] {
const text = getTextWithoutComments(doc, 'css', range)
let matches = findAll(
/(?<prefix>[\W])(?<helper>config|theme|--theme|var)(?<innerPrefix>\(\s*)(?<path>[^)]*?)\s*\)/g,
text,
)
let text = getTextWithoutComments(doc, 'css', range)

// Find every instance of a helper function
let matches = findAll(/\b(?<helper>config|theme|--theme|var)\(/g, text)

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

return matches.map((match) => {
let quotesBefore = ''
let path = match.groups.path
let commaIndex = getFirstCommaIndex(path)
if (commaIndex !== null) {
path = path.slice(0, commaIndex).trimEnd()
}
path = path.replace(/['"]+$/, '').replace(/^['"]+/, (m) => {
quotesBefore = m
return ''
})
let matches = path.match(/^([^\s]+)(?![^\[]*\])(?:\s*\/\s*([^\/\s]+))$/)
if (matches) {
path = matches[1]
let fns: DocumentHelperFunction[] = []

// Collect the first argument of each fn accounting for balanced params
const COMMA = 0x2c
const SLASH = 0x2f
const BACKSLASH = 0x5c
const OPEN_PAREN = 0x28
const CLOSE_PAREN = 0x29
const DOUBLE_QUOTE = 0x22
const SINGLE_QUOTE = 0x27

let len = text.length

for (let match of matches) {
let argsStart = match.index + match[0].length
let argsEnd = null
let pathStart = argsStart
let pathEnd = null
let depth = 1

// Scan until we find a `,` or balanced `)` not in quotes
for (let idx = argsStart; idx < len; ++idx) {
let char = text.charCodeAt(idx)

if (char === BACKSLASH) {
idx += 1
}

//
else if (char === SINGLE_QUOTE || char === DOUBLE_QUOTE) {
while (++idx < len) {
let nextChar = text.charCodeAt(idx)
if (nextChar === BACKSLASH) {
idx += 1
continue
}
if (nextChar === char) break
}
}

//
else if (char === OPEN_PAREN) {
depth += 1
}

//
else if (char === CLOSE_PAREN) {
depth -= 1

if (depth === 0) {
pathEnd ??= idx
argsEnd = idx
break
}
}

//
else if (char === COMMA && depth === 1) {
pathEnd ??= idx
}
}
path = path.replace(/['"]*\s*$/, '')

let startIndex =
match.index +
match.groups.prefix.length +
match.groups.helper.length +
match.groups.innerPrefix.length
if (argsEnd === null) continue

let helper: 'config' | 'theme' | 'var' = 'config'
let helper: 'config' | 'theme' | 'var'

if (match.groups.helper === 'theme' || match.groups.helper === '--theme') {
helper = 'theme'
} else if (match.groups.helper === 'var') {
helper = 'var'
} else if (match.groups.helper === 'config') {
helper = 'config'
} else {
continue
}

return {
let path = text.slice(pathStart, pathEnd)

// Skip leading/trailing whitespace
pathStart += path.match(/^\s+/)?.length ?? 0
pathEnd -= path.match(/\s+$/)?.length ?? 0

// Skip leading/trailing quotes
let quoteStart = path.match(/^['"]+/)?.length ?? 0
let quoteEnd = path.match(/['"]+$/)?.length ?? 0

if (quoteStart && quoteEnd) {
pathStart += quoteStart
pathEnd -= quoteEnd
}

// Clip to the top-level slash
depth = 1
for (let idx = pathStart; idx < pathEnd; ++idx) {
let char = text.charCodeAt(idx)
if (char === BACKSLASH) {
idx += 1
} else if (char === OPEN_PAREN) {
depth += 1
} else if (char === CLOSE_PAREN) {
depth -= 1
} else if (char === SLASH && depth === 1) {
pathEnd = idx
}
}

// Re-slice
path = text.slice(pathStart, pathEnd)

// Skip leading/trailing whitespace
//
// This can happen if we've clipped the path down to before the `/`
pathStart += path.match(/^\s+/)?.length ?? 0
pathEnd -= path.match(/\s+$/)?.length ?? 0

// Re-slice
path = text.slice(pathStart, pathEnd)

// Skip leading/trailing quotes
quoteStart = path.match(/^['"]+/)?.length ?? 0
quoteEnd = path.match(/['"]+$/)?.length ?? 0

pathStart += quoteStart
pathEnd -= quoteEnd

// Re-slice
path = text.slice(pathStart, pathEnd)

fns.push({
helper,
path,
ranges: {
full: absoluteRange(
{
start: indexToPosition(text, startIndex),
end: indexToPosition(text, startIndex + match.groups.path.length),
start: indexToPosition(text, argsStart),
end: indexToPosition(text, argsEnd),
},
range,
),
path: absoluteRange(
{
start: indexToPosition(text, startIndex + quotesBefore.length),
end: indexToPosition(text, startIndex + quotesBefore.length + path.length),
start: indexToPosition(text, pathStart),
end: indexToPosition(text, pathEnd),
},
range,
),
},
}
})
})
}

return fns
}

export function indexToPosition(str: string, index: number): Position {
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-tailwindcss/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Calculate swatches for HSL colors with angular units ([#1360](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1360))
- Fix error when using VSCode < 1.78 ([#1353](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1353))
- Don’t skip suggesting empty variant implementations ([#1352](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1352))
- Handle helper function lookups in nested parens ([#1354](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1354))

# 0.14.16

Expand Down
Loading