split the variant guard into one rule per syntax form

three rounds of review kept finding holes in a single pattern because one regex
was trying to know where the value ends in three different syntaxes at once

each form now stops at what actually ends it. a brace for a jsx expression, the
opening quote for an attribute so it cannot run into a neighbouring one, a comma
or brace for an object literal where quotes are normal

the array already tests each rule on its own so this needs no other change
This commit is contained in:
Huzaifa Iftikhar 2026-08-28 05:01:29 +05:00 • committed by HuzaifaChaudary
parent 0d028ed365
commit f2bc150471
2 changed files with 24 additions and 12 deletions

View file

@ -125,6 +125,9 @@ describe('is-safe-migration', async () => {
[`<Button variant={required ? 'secondary' : 'outline'} />`, 'outline'],
[`<Button variant={isActive(foo, bar) ? "outline" : "ghost"} />`, 'outline'],
[`<Button variant={isActive(getState(foo, bar), x) ? "outline" : "ghost"} />`, 'outline'],
[`<div :variant="active ? 'ghost' : 'outline'"></div>`, 'outline'],
[`Button({ variant: (value ?? "outline") })`, 'outline'],
[`Button({ variant: theme === "dark" ? "outline" : "ghost" })`, 'outline'],
[`<Button variant={cond ? ("outline") : "ghost"} />`, 'outline'],
[`<Button variant={cond ? "ghost" : ("outline")} />`, 'outline'],
])('does not replace classes in invalid positions #%#', async (example, candidate) => {

View file

@ -8,15 +8,8 @@ const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=',
// A parenthesised group with one level of nesting, so a call in a condition keeps
// its own commas instead of ending the match early.
const PAREN_GROUP = String.raw`\((?:[^()]|\([^()]*\))*\)`
// What may sit between a `variant` prop and its string literal. Inside a brace a
// quoted branch is ordinary, so quotes can be crossed but braces cannot. Outside
// one a quote closes the attribute value, so crossing it would let the guard
// reach a neighbouring attribute such as Vue's `:class`.
const VARIANT_CONDITION = [
String.raw`\{(?:[^{}]|${PAREN_GROUP})*?(?:\?\?|\?|:)\s*`,
String.raw`(?:[^{},()'"\`]|${PAREN_GROUP})*?(?:\?\?|\?|:)\s*`,
String.raw`\{`,
].join('|')
// A ternary or nullish operator, then whatever sits before the string literal.
const CONDITION_TAIL = String.raw`(?:\?\?|\?|:)\s*\(*\s*['"\`]$`
const CONDITIONAL_TEMPLATE_SYNTAX = [
// Skip any generic attributes like `xxx="shadow"`,
@ -31,9 +24,25 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [
// Alpine
/wire:[^\s]*?$/,
// shadcn/ui variants, including a ternary or nullish branch between the prop
// and the literal
new RegExp(String.raw`variant\s*[:=]\s*(?:${VARIANT_CONDITION})?\(*\s*['"\`]$`),
// shadcn/ui variants. A conditional can sit between the prop and the literal,
// so each form below stops at whatever actually ends its own value. Getting
// that boundary wrong in either direction is costly: too narrow and a prop is
// rewritten as a class, too wide and a real class stops being migrated.
// `variant="outline"`, `variant={"outline"}`, `variant: "outline"`
/variant\s*[:=]\s*\{?['"`]$/,
// `variant={cond ? "outline" : "ghost"}` — a brace ends the value, and a
// quoted branch inside it is ordinary
new RegExp(String.raw`variant\s*[:=]\s*\{(?:[^{}]|${PAREN_GROUP})*?${CONDITION_TAIL}`),
// `:variant="active ? 'outline' : 'ghost'"` — the opening quote ends the value,
// so the match cannot run on into a neighbouring attribute such as `:class`
new RegExp(String.raw`variant\s*=\s*(["'])(?:(?!\1)[^{}])*?${CONDITION_TAIL}`),
// `{ variant: theme === "dark" ? "outline" : "ghost" }` — a comma or brace ends
// the value, quotes do not
new RegExp(String.raw`variant\s*:\s*(?:[^{},]|${PAREN_GROUP})*?${CONDITION_TAIL}`),
]
const NEXT_PLACEHOLDER_PROP = /placeholder=\{?['"`]$/
const VUE_3_EMIT = /\b\$?emit\(['"`]$/