Jelajahi Sumber

fix(compiler-vapor): avoid unsafe repeated expression replacements

Record expression variable ranges during analysis and apply replacements by
source range instead of broad string/regex replacement.

This prevents repeated expression caching from rewriting string literals or
partial identifier matches, and keeps overlapping repeated expressions stable by
processing longer expressions first.

Also reduces repeated iteration in shouldDeclareVariable with a single scan and
early exits.
daiwei 2 hari lalu
induk
melakukan
72988d0553

+ 76 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/expression.spec.ts.snap

@@ -25,6 +25,22 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: expression > cache expressions > absorbed member expression declaration skips string literals 1`] = `
+"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>")
+
+export function render(_ctx) {
+  const n0 = t0()
+  const n1 = t0()
+  _renderEffect(() => {
+    const __foo_bar_foo_bar_baz = 'foo_bar' + _ctx.foo[_ctx.bar] + _ctx.baz
+    _setProp(n0, "id", __foo_bar_foo_bar_baz)
+    _setProp(n1, "id", __foo_bar_foo_bar_baz)
+  })
+  return [n0, n1]
+}"
+`;
+
 exports[`compiler: expression > cache expressions > cache variable used in both property shorthand and normal binding 1`] = `
 "import { setStyle as _setStyle, setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", 1)
@@ -182,6 +198,29 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: expression > cache expressions > overlapping repeated expressions avoid stale declarations 1`] = `
+"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>")
+
+export function render(_ctx) {
+  const n0 = t0()
+  const n1 = t0()
+  const n2 = t0()
+  const n3 = t0()
+  _renderEffect(() => {
+    const _foo = _ctx.foo
+    const _bar = _ctx.bar
+    const _foo_bar_baz = _foo + _bar + _ctx.baz
+    const _foo_bar = _foo + _bar
+    _setProp(n0, "id", _foo_bar)
+    _setProp(n1, "id", _foo_bar)
+    _setProp(n2, "title", _foo_bar_baz)
+    _setProp(n3, "title", _foo_bar_baz)
+  })
+  return [n0, n1, n2, n3]
+}"
+`;
+
 exports[`compiler: expression > cache expressions > repeated expression in expressions 1`] = `
 "import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>")
@@ -201,6 +240,43 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: expression > cache expressions > repeated expression replacement respects identifier boundaries 1`] = `
+"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>")
+
+export function render(_ctx) {
+  const n0 = t0()
+  const n1 = t0()
+  const n2 = t0()
+  _renderEffect(() => {
+    const _foo = _ctx.foo
+    const _foo_bar = _foo + _ctx.bar
+    _setProp(n0, "id", _foo_bar)
+    _setProp(n1, "id", _foo_bar)
+    _setProp(n2, "title", _foo + _ctx.barbaz)
+  })
+  return [n0, n1, n2]
+}"
+`;
+
+exports[`compiler: expression > cache expressions > repeated expression replacement skips string literals 1`] = `
+"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>")
+
+export function render(_ctx) {
+  const n0 = t0()
+  const n1 = t0()
+  const n2 = t0()
+  _renderEffect(() => {
+    const _foo_bar = _ctx.foo + _ctx.bar
+    _setProp(n0, "id", _foo_bar)
+    _setProp(n1, "id", _foo_bar)
+    _setProp(n2, "title", 'foo + bar' + _ctx.baz)
+  })
+  return [n0, n1, n2]
+}"
+`;
+
 exports[`compiler: expression > cache expressions > repeated expressions 1`] = `
 "import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>")

+ 49 - 0
packages/compiler-vapor/__tests__/transforms/expression.spec.ts

@@ -115,6 +115,55 @@ describe('compiler: expression', () => {
       expect(code).contains('_setProp(n2, "id", _foo + _foo_bar)')
     })
 
+    test('repeated expression replacement skips string literals', () => {
+      const { code } = compileWithExpression(`
+        <div :id="foo + bar"></div>
+        <div :id="foo + bar"></div>
+        <div :title="'foo + bar' + baz"></div>
+      `)
+      expect(code).matchSnapshot()
+      expect(code).contains('const _foo_bar = _ctx.foo + _ctx.bar')
+      expect(code).contains(`_setProp(n2, "title", 'foo + bar' + _ctx.baz)`)
+      expect(code).not.contains(`'_foo_bar'`)
+    })
+
+    test('repeated expression replacement respects identifier boundaries', () => {
+      const { code } = compileWithExpression(`
+        <div :id="foo + bar"></div>
+        <div :id="foo + bar"></div>
+        <div :title="foo + barbaz"></div>
+      `)
+      expect(code).matchSnapshot()
+      expect(code).contains('_setProp(n2, "title", _foo + _ctx.barbaz)')
+      expect(code).not.contains('_ctx.foo_barbaz')
+    })
+
+    test('overlapping repeated expressions avoid stale declarations', () => {
+      const { code } = compileWithExpression(`
+        <div :id="foo + bar"></div>
+        <div :id="foo + bar"></div>
+        <div :title="foo + bar + baz"></div>
+        <div :title="foo + bar + baz"></div>
+      `)
+      expect(code).matchSnapshot()
+      expect(code).contains('const _foo_bar = _foo + _bar')
+      expect(code).contains('const _foo_bar_baz = _foo + _bar + _ctx.baz')
+      expect(code).contains('_setProp(n2, "title", _foo_bar_baz)')
+      expect(code).contains('_setProp(n3, "title", _foo_bar_baz)')
+    })
+
+    test('absorbed member expression declaration skips string literals', () => {
+      const { code } = compileWithExpression(`
+        <div :id="'foo_bar' + foo[bar] + baz"></div>
+        <div :id="'foo_bar' + foo[bar] + baz"></div>
+      `)
+      expect(code).matchSnapshot()
+      expect(code).contains(
+        `const __foo_bar_foo_bar_baz = 'foo_bar' + _ctx.foo[_ctx.bar] + _ctx.baz`,
+      )
+      expect(code).not.contains(`'foo[bar]'`)
+    })
+
     test('repeated simple function calls', () => {
       const { code } = compileWithExpression(`
         <div :id="foo()"></div>

+ 316 - 133
packages/compiler-vapor/src/generators/expression.ts

@@ -311,6 +311,34 @@ type DeclarationValue = {
   exps?: Set<SimpleExpressionNode>
   seenCount?: number
 }
+type SourceRange = {
+  start: number
+  end: number
+}
+type VariableUse = {
+  name: string
+  loc?: SourceRange
+}
+type ExpressionRecord = {
+  variables: VariableUse[]
+}
+type ExpressionAnalysis = {
+  seenVariable: Record<string, number>
+  variableToExpMap: Map<string, Set<SimpleExpressionNode>>
+  expressionRecords: Map<SimpleExpressionNode, ExpressionRecord>
+  seenIdentifier: Set<string>
+  updatedVariable: Set<string>
+}
+type SeenExpression = {
+  count: number
+  first: SimpleExpressionNode
+}
+type ContentReplacement = {
+  start: number
+  end: number
+  content: string
+}
+type ReplacementPlan = Map<SimpleExpressionNode, ContentReplacement[]>
 
 export function processExpressions(
   context: CodegenContext,
@@ -325,7 +353,7 @@ export function processExpressions(
   const {
     seenVariable,
     variableToExpMap,
-    expToVariableMap,
+    expressionRecords,
     seenIdentifier,
     updatedVariable,
   } = analyzeExpressions(expressions)
@@ -337,7 +365,7 @@ export function processExpressions(
     context,
     seenVariable,
     variableToExpMap,
-    expToVariableMap,
+    expressionRecords,
     seenIdentifier,
     updatedVariable,
     reservedNames,
@@ -351,7 +379,7 @@ export function processExpressions(
     expressions,
     varDeclarations,
     updatedVariable,
-    expToVariableMap,
+    expressionRecords,
     reservedNames,
     expressionReplacements,
   )
@@ -366,24 +394,28 @@ export function processExpressions(
   }
 }
 
-function analyzeExpressions(expressions: SimpleExpressionNode[]) {
+function analyzeExpressions(
+  expressions: SimpleExpressionNode[],
+): ExpressionAnalysis {
   const seenVariable: Record<string, number> = Object.create(null)
   const variableToExpMap = new Map<string, Set<SimpleExpressionNode>>()
-  const expToVariableMap = new Map<
-    SimpleExpressionNode,
-    Array<{
-      name: string
-      loc?: { start: number; end: number }
-    }>
-  >()
+  const expressionRecords = new Map<SimpleExpressionNode, ExpressionRecord>()
   const seenIdentifier = new Set<string>()
   const updatedVariable = new Set<string>()
 
+  const getRecord = (exp: SimpleExpressionNode): ExpressionRecord => {
+    let record = expressionRecords.get(exp)
+    if (!record) {
+      expressionRecords.set(exp, (record = { variables: [] }))
+    }
+    return record
+  }
+
   const registerVariable = (
     name: string,
     exp: SimpleExpressionNode,
     isIdentifier: boolean,
-    loc?: { start: number; end: number },
+    loc?: SourceRange,
     parentStack: Node[] = [],
   ) => {
     if (isIdentifier) seenIdentifier.add(name)
@@ -393,9 +425,7 @@ function analyzeExpressions(expressions: SimpleExpressionNode[]) {
       (variableToExpMap.get(name) || new Set()).add(exp),
     )
 
-    const variables = expToVariableMap.get(exp) || []
-    variables.push({ name, loc })
-    expToVariableMap.set(exp, variables)
+    getRecord(exp).variables.push({ name, loc })
 
     if (
       parentStack.some(
@@ -457,7 +487,7 @@ function analyzeExpressions(expressions: SimpleExpressionNode[]) {
     seenVariable,
     seenIdentifier,
     variableToExpMap,
-    expToVariableMap,
+    expressionRecords,
     updatedVariable,
   }
 }
@@ -488,23 +518,15 @@ function processRepeatedVariables(
   context: CodegenContext,
   seenVariable: Record<string, number>,
   variableToExpMap: Map<string, Set<SimpleExpressionNode>>,
-  expToVariableMap: Map<
-    SimpleExpressionNode,
-    Array<{ name: string; loc?: { start: number; end: number } }>
-  >,
+  expressionRecords: Map<SimpleExpressionNode, ExpressionRecord>,
   seenIdentifier: Set<string>,
   updatedVariable: Set<string>,
   reservedNames: Set<string>,
   expressionReplacements: Map<SimpleExpressionNode, SimpleExpressionNode>,
 ): DeclarationValue[] {
   const declarations: DeclarationValue[] = []
-  const expToReplacementMap = new Map<
-    SimpleExpressionNode,
-    Array<{
-      name: string
-      locs: { start: number; end: number }[]
-    }>
-  >()
+  const declaredNames = new Set<string>()
+  const replacementPlan: ReplacementPlan = new Map()
 
   for (const [name, exps] of variableToExpMap) {
     if (updatedVariable.has(name)) continue
@@ -524,25 +546,26 @@ function processRepeatedVariables(
       // replaced during context.withId(..., ids)
       exps.forEach(node => {
         if (node.ast && varName !== name) {
-          const replacements = expToReplacementMap.get(node) || []
-          replacements.push({
-            name: varName,
-            locs: expToVariableMap.get(node)!.reduce(
-              (locs, v) => {
-                if (v.name === name && v.loc) locs.push(v.loc)
-                return locs
-              },
-              [] as { start: number; end: number }[],
-            ),
-          })
-          expToReplacementMap.set(node, replacements)
+          for (const variable of getExpressionVariables(
+            expressionRecords,
+            node,
+          )) {
+            if (variable.name === name && variable.loc) {
+              queueContentReplacement(replacementPlan, node, {
+                start: variable.loc.start - 1,
+                end: variable.loc.end - 1,
+                content: varName,
+              })
+            }
+          }
         }
       })
 
       if (
-        !declarations.some(d => d.name === varName) &&
-        (!isIdentifier || shouldDeclareVariable(name, expToVariableMap, exps))
+        !declaredNames.has(varName) &&
+        (!isIdentifier || shouldDeclareVariable(name, expressionRecords, exps))
       ) {
+        declaredNames.add(varName)
         declarations.push({
           name: varName,
           isIdentifier,
@@ -558,78 +581,105 @@ function processRepeatedVariables(
     }
   }
 
-  for (const [exp, replacements] of expToReplacementMap) {
-    let content = getProcessedExpression(exp, expressionReplacements).content
-    replacements
-      .flatMap(({ name, locs }) =>
-        locs.map(({ start, end }) => ({ start, end, name })),
-      )
-      .sort((a, b) => b.end - a.end)
-      .forEach(({ start, end, name }) => {
-        content = content.slice(0, start - 1) + name + content.slice(end - 1)
-      })
-
-    setExpressionReplacement(
-      expressionReplacements,
-      exp,
-      content,
-      parseExp(context, content),
-    )
-  }
+  applyReplacementPlan(context, expressionReplacements, replacementPlan)
 
   return declarations
 }
 
 function shouldDeclareVariable(
   name: string,
-  expToVariableMap: Map<
-    SimpleExpressionNode,
-    Array<{ name: string; loc?: { start: number; end: number } }>
-  >,
+  expressionRecords: Map<SimpleExpressionNode, ExpressionRecord>,
   exps: Set<SimpleExpressionNode>,
 ): boolean {
-  const vars = Array.from(exps, exp =>
-    expToVariableMap.get(exp)!.map(v => v.name),
-  )
+  const variableUsages: VariableUse[][] = []
+  let allSingleVariable = true
+  let hasRepeatedName = false
+  let hasDifferentLength = false
+
+  outer: for (const exp of exps) {
+    const variables = getExpressionVariables(expressionRecords, exp)
+
+    if (allSingleVariable && variables.length !== 1) {
+      allSingleVariable = false
+    }
+
+    if (
+      !hasDifferentLength &&
+      variableUsages.length > 0 &&
+      variables.length !== variableUsages[0].length
+    ) {
+      hasDifferentLength = true
+    }
+
+    let nameCount = 0
+    for (const variable of variables) {
+      if (variable.name === name && ++nameCount > 1) {
+        hasRepeatedName = true
+        break outer
+      }
+    }
+
+    variableUsages.push(variables)
+  }
+
   // assume name equals to `foo`
   // if each expression only references `foo`, declaration is needed
   // to avoid reactivity tracking
   // e.g., [[foo],[foo]]
-  if (vars.every(v => v.length === 1)) {
+  if (allSingleVariable) {
     return true
   }
 
   // if `foo` appears multiple times in one array, declaration is needed
   // e.g., [[foo,foo]]
-  if (vars.some(v => v.filter(e => e === name).length > 1)) {
+  if (hasRepeatedName) {
     return true
   }
 
-  const first = vars[0]
+  const first = variableUsages[0]
   // if arrays have different lengths, declaration is needed
   // e.g., [[foo],[foo,bar]]
-  if (vars.some(v => v.length !== first.length)) {
+  if (hasDifferentLength) {
     // special case, no declaration needed if one array is a subset of the other
     // because they will be treated as repeated expressions
     // e.g., [[foo,bar],[foo,foo,bar]] -> const foo_bar = _ctx.foo + _ctx.bar
-    if (
-      vars.some(
-        v => v.length > first.length && v.every(e => first.includes(e)),
-      ) ||
-      vars.some(v => first.length > v.length && first.every(e => v.includes(e)))
-    ) {
-      return false
+    for (const variables of variableUsages) {
+      if (variables.length === first.length) {
+        continue
+      }
+
+      const longer = variables.length > first.length ? variables : first
+      const shorter = variables.length > first.length ? first : variables
+      const shorterNames = new Set<string>()
+      for (const variable of shorter) {
+        shorterNames.add(variable.name)
+      }
+
+      let isSubset = true
+      for (const variable of longer) {
+        if (!shorterNames.has(variable.name)) {
+          isSubset = false
+          break
+        }
+      }
+      if (isSubset) {
+        return false
+      }
     }
     return true
   }
   // if arrays are identical, no declaration needed
   // because they will be treated as repeated expressions
   // e.g., [[foo,bar],[foo,bar]] -> const foo_bar = _ctx.foo + _ctx.bar
-  if (vars.every(v => v.every((e, idx) => e === first[idx]))) {
-    return false
+  for (const variables of variableUsages) {
+    for (let i = 0; i < variables.length; i++) {
+      if (variables[i].name !== first[i].name) {
+        return true
+      }
+    }
   }
 
-  return true
+  return false
 }
 
 function processRepeatedExpressions(
@@ -637,39 +687,32 @@ function processRepeatedExpressions(
   expressions: SimpleExpressionNode[],
   varDeclarations: DeclarationValue[],
   updatedVariable: Set<string>,
-  expToVariableMap: Map<
-    SimpleExpressionNode,
-    Array<{ name: string; loc?: { start: number; end: number } }>
-  >,
+  expressionRecords: Map<SimpleExpressionNode, ExpressionRecord>,
   reservedNames: Set<string>,
   expressionReplacements: Map<SimpleExpressionNode, SimpleExpressionNode>,
 ): DeclarationValue[] {
   const declarations: DeclarationValue[] = []
-  const seenExp = expressions.reduce(
-    (acc, exp) => {
-      const vars = expToVariableMap.get(exp)
-      if (!vars) return acc
-
-      const processed = getProcessedExpression(exp, expressionReplacements)
-      const variables = vars.map(v => v.name)
-      // only handle expressions that are not identifiers
-      if (
-        processed.ast &&
-        processed.ast.type !== 'Identifier' &&
-        !(variables && variables.some(v => updatedVariable.has(v))) &&
-        // skip expressions containing globally allowed identifiers
-        // (e.g. Math.random(), Date.now() + foo) - they are not reactive
-        // and may involve impure calls with side effects
-        !variables.some(v => isGloballyAllowed(v))
-      ) {
-        acc[processed.content] = (acc[processed.content] || 0) + 1
+  const seenExp = new Map<string, SeenExpression>()
+
+  for (const exp of expressions) {
+    const vars = expressionRecords.get(exp)?.variables
+    if (!vars) continue
+
+    const processed = getProcessedExpression(exp, expressionReplacements)
+    if (canCacheExpression(processed, vars, updatedVariable)) {
+      const seen = seenExp.get(processed.content)
+      if (seen) {
+        seen.count++
+      } else {
+        seenExp.set(processed.content, { count: 1, first: exp })
       }
-      return acc
-    },
-    Object.create(null) as Record<string, number>,
-  )
+    }
+  }
 
-  Object.entries(seenExp).forEach(([content, count]) => {
+  const repeatedExpressions = [...seenExp].sort(
+    ([contentA], [contentB]) => contentB.length - contentA.length,
+  )
+  for (const [content, { count, first }] of repeatedExpressions) {
     if (count > 1) {
       // foo + baz -> foo_baz
       // if foo and baz have no other references, we don't need to declare separate variables
@@ -679,7 +722,7 @@ function processRepeatedExpressions(
       // const foo_baz = foo + baz
       // we can generate:
       // const foo_baz = _ctx.foo + _ctx.baz
-      const delVars: Record<string, string> = {}
+      const removedDeclarations: Array<{ name: string; rawName: string }> = []
       for (let i = varDeclarations.length - 1; i >= 0; i--) {
         const item = varDeclarations[i]
         if (!item.exps || !item.seenCount) continue
@@ -690,24 +733,26 @@ function processRepeatedExpressions(
               content && item.seenCount === count,
         )
         if (shouldRemove) {
-          delVars[item.name] = item.rawName!
+          removedDeclarations.push({
+            name: item.name,
+            rawName: item.rawName!,
+          })
           reservedNames.delete(item.name)
           varDeclarations.splice(i, 1)
         }
       }
-      const matchedExpression = expressions.find(
-        exp =>
-          getProcessedExpression(exp, expressionReplacements).content ===
-          content,
-      )!
       const value = extend(
         {},
-        getProcessedExpression(matchedExpression, expressionReplacements),
+        getProcessedExpression(first, expressionReplacements),
       )
-      Object.keys(delVars).forEach(name => {
-        value.content = value.content.replace(name, delVars[name])
+      const restorePlan: ContentReplacement[] = []
+      for (const { name, rawName } of removedDeclarations) {
+        restorePlan.push(...findIdentifierReplacements(value, name, rawName))
+      }
+      if (restorePlan.length) {
+        value.content = applyContentReplacements(value.content, restorePlan)
         if (value.ast) value.ast = parseExp(context, value.content)
-      })
+      }
       const varName = getUniqueDeclarationName(
         genVarName(content),
         reservedNames,
@@ -718,7 +763,7 @@ function processRepeatedExpressions(
       })
 
       // assume content equals to `foo + baz`
-      expressions.forEach(exp => {
+      for (const exp of expressions) {
         const processed = getProcessedExpression(exp, expressionReplacements)
         // foo + baz -> foo_baz
         if (processed.content === content) {
@@ -726,24 +771,166 @@ function processRepeatedExpressions(
         }
         // foo + foo + baz -> foo + foo_baz
         else if (processed.content.includes(content)) {
-          const replacedContent = processed.content.replace(
-            new RegExp(escapeRegExp(content), 'g'),
+          const replacements = findContentReplacements(
+            processed,
+            content,
             varName,
           )
-          setExpressionReplacement(
-            expressionReplacements,
-            exp,
-            replacedContent,
-            parseExp(context, replacedContent),
-          )
+          if (replacements.length) {
+            const replacedContent = applyContentReplacements(
+              processed.content,
+              replacements,
+            )
+            setExpressionReplacement(
+              expressionReplacements,
+              exp,
+              replacedContent,
+              parseExp(context, replacedContent),
+            )
+          }
         }
-      })
+      }
     }
-  })
+  }
 
   return declarations
 }
 
+function canCacheExpression(
+  processed: SimpleExpressionNode,
+  vars: VariableUse[],
+  updatedVariable: Set<string>,
+): boolean {
+  if (!processed.ast || processed.ast.type === 'Identifier') {
+    return false
+  }
+
+  for (const { name } of vars) {
+    if (updatedVariable.has(name) || isGloballyAllowed(name)) {
+      return false
+    }
+  }
+
+  return true
+}
+
+function getExpressionVariables(
+  expressionRecords: Map<SimpleExpressionNode, ExpressionRecord>,
+  exp: SimpleExpressionNode,
+): VariableUse[] {
+  return expressionRecords.get(exp)?.variables || []
+}
+
+function queueContentReplacement(
+  replacementPlan: ReplacementPlan,
+  exp: SimpleExpressionNode,
+  replacement: ContentReplacement,
+): void {
+  const replacements = replacementPlan.get(exp)
+  if (replacements) {
+    replacements.push(replacement)
+  } else {
+    replacementPlan.set(exp, [replacement])
+  }
+}
+
+function applyReplacementPlan(
+  context: CodegenContext,
+  expressionReplacements: Map<SimpleExpressionNode, SimpleExpressionNode>,
+  replacementPlan: ReplacementPlan,
+): void {
+  for (const [exp, replacements] of replacementPlan) {
+    if (!replacements.length) continue
+
+    const content = applyContentReplacements(
+      getProcessedExpression(exp, expressionReplacements).content,
+      replacements,
+    )
+    setExpressionReplacement(
+      expressionReplacements,
+      exp,
+      content,
+      parseExp(context, content),
+    )
+  }
+}
+
+function findContentReplacements(
+  exp: SimpleExpressionNode,
+  content: string,
+  replacement: string,
+): ContentReplacement[] {
+  const identifiers = getIdentifierRanges(exp)
+  if (!identifiers.length) return []
+
+  const replacements: ContentReplacement[] = []
+  let searchStart = 0
+  let start = exp.content.indexOf(content, searchStart)
+  while (start !== -1) {
+    const end = start + content.length
+    let canReplace = false
+    for (const identifier of identifiers) {
+      if (start >= identifier.end || end <= identifier.start) {
+        continue
+      }
+      if (start > identifier.start || end < identifier.end) {
+        canReplace = false
+        break
+      }
+      canReplace = true
+    }
+    if (canReplace) {
+      replacements.push({ start, end, content: replacement })
+      searchStart = end
+    } else {
+      searchStart = start + 1
+    }
+    start = exp.content.indexOf(content, searchStart)
+  }
+
+  return replacements
+}
+
+function findIdentifierReplacements(
+  exp: SimpleExpressionNode,
+  name: string,
+  replacement: string,
+): ContentReplacement[] {
+  const replacements: ContentReplacement[] = []
+  for (const { start, end } of getIdentifierRanges(exp)) {
+    if (exp.content.slice(start, end) === name) {
+      replacements.push({ start, end, content: replacement })
+    }
+  }
+  return replacements
+}
+
+function getIdentifierRanges(exp: SimpleExpressionNode): SourceRange[] {
+  if (!exp.ast || typeof exp.ast !== 'object') return []
+
+  const identifiers: SourceRange[] = []
+  walkIdentifiers(
+    exp.ast,
+    id => {
+      identifiers.push({ start: id.start! - 1, end: id.end! - 1 })
+    },
+    false,
+  )
+  return identifiers
+}
+
+function applyContentReplacements(
+  content: string,
+  replacements: ContentReplacement[],
+): string {
+  replacements
+    .sort((a, b) => b.start - a.start)
+    .forEach(({ start, end, content: replacement }) => {
+      content = content.slice(0, start) + replacement + content.slice(end)
+    })
+  return content
+}
+
 function genDeclarations(
   declarations: DeclarationValue[],
   context: CodegenContext,
@@ -785,10 +972,6 @@ function genDeclarations(
   return { ids, frag, varNames: [...varNames] }
 }
 
-function escapeRegExp(string: string) {
-  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
 function parseExp(context: CodegenContext, content: string): Node {
   return parseExpression(
     `(${content})`,