This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-text.git


The following commit(s) were added to refs/heads/master by this push:
     new 43d2c8f2 [TEXT-179] StringSubstitutor incorrectly removes the escape 
character in "$${${a}" (#752)
43d2c8f2 is described below

commit 43d2c8f288e56cb747f504e30f856e779c6c82c5
Author: Gary Gregory <[email protected]>
AuthorDate: Wed Jun 10 20:12:53 2026 -0400

    [TEXT-179] StringSubstitutor incorrectly removes the escape character in 
"$${${a}" (#752)
---
 .../org/apache/commons/text/StringSubstitutor.java | 63 ++++++++++++----------
 .../apache/commons/text/StringSubstitutorTest.java |  2 -
 2 files changed, 36 insertions(+), 29 deletions(-)

diff --git a/src/main/java/org/apache/commons/text/StringSubstitutor.java 
b/src/main/java/org/apache/commons/text/StringSubstitutor.java
index 9fce66de..2d909080 100644
--- a/src/main/java/org/apache/commons/text/StringSubstitutor.java
+++ b/src/main/java/org/apache/commons/text/StringSubstitutor.java
@@ -1390,19 +1390,18 @@ public class StringSubstitutor {
     }
 
     /**
-     * Recursive handler for multiple levels of interpolation. This is the 
main interpolation method, which resolves the
-     * values of all variable references contained in the passed in text.
+     * Recursive handler for multiple levels of interpolation. This is the 
main interpolation method, which resolves the values of all variable references
+     * contained in the passed in text.
      *
-     * @param builder the string builder to substitute into, not null.
-     * @param offset the start offset within the builder, must be valid.
-     * @param length the length within the builder to be processed, must be 
valid.
+     * @param builder        the string builder to substitute into, not null.
+     * @param offset         the start offset within the builder, must be 
valid.
+     * @param length         the length within the builder to be processed, 
must be valid.
      * @param priorVariables the stack keeping track of the replaced 
variables, may be null.
      * @return The result.
      * @throws IllegalArgumentException if variable is not found and 
<code>isEnableUndefinedVariableException() == true</code>.
      * @since 1.9
      */
-    private Result substitute(final TextStringBuilder builder, final int 
offset, final int length,
-        List<String> priorVariables) {
+    private Result substitute(final TextStringBuilder builder, final int 
offset, final int length, List<String> priorVariables) {
         Objects.requireNonNull(builder, "builder");
         final StringMatcher prefixMatcher = getVariablePrefixMatcher();
         final StringMatcher suffixMatcher = getVariableSuffixMatcher();
@@ -1412,7 +1411,6 @@ public class StringSubstitutor {
         final boolean substitutionInValuesDisabled = 
isDisableSubstitutionInValues();
         final boolean undefinedVariableException = 
isEnableUndefinedVariableException();
         final boolean preserveEscapes = isPreserveEscapes();
-
         boolean altered = false;
         int lengthChange = 0;
         int bufEnd = offset + length;
@@ -1447,7 +1445,6 @@ public class StringSubstitutor {
                         pos += endMatchLen;
                         continue;
                     }
-
                     endMatchLen = suffixMatcher.isMatch(builder, pos, offset, 
bufEnd);
                     if (endMatchLen == 0) {
                         pos++;
@@ -1455,19 +1452,21 @@ public class StringSubstitutor {
                         // found variable end marker
                         if (nestedVarCount == 0) {
                             if (escPos >= 0) {
+                                final boolean 
escapedVariableStartsWithNestedPrefix = prefixMatcher.isMatch(builder, startPos 
+ startMatchLen, offset,
+                                        bufEnd) != 0;
+                                final boolean hasOuterSuffix = 
hasLaterSuffix(builder, pos + endMatchLen, offset, bufEnd, suffixMatcher);
+                                pos = escapedVariableStartsWithNestedPrefix && 
!hasOuterSuffix ? escPos : startPos + 1;
                                 // delete escape
                                 builder.deleteCharAt(escPos);
                                 escPos = -1;
                                 lengthChange--;
                                 altered = true;
                                 bufEnd--;
-                                pos = startPos + 1;
                                 startPos--;
                                 continue outer;
                             }
                             // get var name
-                            String varNameExpr = builder.midString(startPos + 
startMatchLen,
-                                pos - startPos - startMatchLen);
+                            String varNameExpr = builder.midString(startPos + 
startMatchLen, pos - startPos - startMatchLen);
                             if (substitutionInVariablesEnabled) {
                                 final TextStringBuilder bufName = new 
TextStringBuilder(varNameExpr);
                                 substitute(bufName, 0, bufName.length());
@@ -1475,41 +1474,33 @@ public class StringSubstitutor {
                             }
                             pos += endMatchLen;
                             final int endPos = pos;
-
                             String varName = varNameExpr;
                             String varDefaultValue = null;
-
                             if (valueDelimMatcher != null) {
                                 final char[] varNameExprChars = 
varNameExpr.toCharArray();
                                 int valueDelimiterMatchLen = 0;
                                 for (int i = 0; i < varNameExprChars.length; 
i++) {
                                     // if there's any nested variable when 
nested variable substitution disabled,
                                     // then stop resolving name and default 
value.
-                                    if (!substitutionInVariablesEnabled && 
prefixMatcher.isMatch(varNameExprChars, i, i,
-                                        varNameExprChars.length) != 0) {
+                                    if (!substitutionInVariablesEnabled && 
prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                                         break;
                                     }
-                                    if 
(valueDelimMatcher.isMatch(varNameExprChars, i, 0,
-                                        varNameExprChars.length) != 0) {
-                                        valueDelimiterMatchLen = 
valueDelimMatcher.isMatch(varNameExprChars, i, 0,
-                                            varNameExprChars.length);
+                                    if 
(valueDelimMatcher.isMatch(varNameExprChars, i, 0, varNameExprChars.length) != 
0) {
+                                        valueDelimiterMatchLen = 
valueDelimMatcher.isMatch(varNameExprChars, i, 0, varNameExprChars.length);
                                         varName = varNameExpr.substring(0, i);
                                         varDefaultValue = 
varNameExpr.substring(i + valueDelimiterMatchLen);
                                         break;
                                     }
                                 }
                             }
-
                             // on the first call initialize priorVariables
                             if (priorVariables == null) {
                                 priorVariables = new ArrayList<>();
                                 priorVariables.add(builder.midString(offset, 
length));
                             }
-
                             // handle cyclic substitution
                             checkCyclicSubstitution(varName, priorVariables);
                             priorVariables.add(varName);
-
                             // resolve the variable
                             String varValue = resolveVariable(varName, 
builder, startPos, endPos);
                             if (varValue == null) {
@@ -1528,11 +1519,9 @@ public class StringSubstitutor {
                                 bufEnd += change;
                                 lengthChange += change;
                             } else if (undefinedVariableException) {
-                                throw new IllegalArgumentException(
-                                    String.format("Cannot resolve variable 
'%s' (enableSubstitutionInVariables=%s).",
-                                        varName, 
substitutionInVariablesEnabled));
+                                throw new 
IllegalArgumentException(String.format("Cannot resolve variable '%s' 
(enableSubstitutionInVariables=%s).", varName,
+                                        substitutionInVariablesEnabled));
                             }
-
                             // remove variable from the cyclic stack
                             priorVariables.remove(priorVariables.size() - 1);
                             break;
@@ -1546,6 +1535,26 @@ public class StringSubstitutor {
         return new Result(altered, lengthChange);
     }
 
+    /**
+     * Checks whether the specified buffer contains a variable suffix after 
the given position.
+     *
+     * @param builder       the string builder to check, not null.
+     * @param pos           the position to start checking from.
+     * @param offset        the start offset within the builder, must be valid.
+     * @param bufEnd        the end offset within the builder, must be valid.
+     * @param suffixMatcher the suffix matcher to use, not null.
+     * @return true if a suffix is found after the given position.
+     */
+    private boolean hasLaterSuffix(final TextStringBuilder builder, int pos, 
final int offset, final int bufEnd, final StringMatcher suffixMatcher) {
+        while (pos < bufEnd) {
+            if (suffixMatcher.isMatch(builder, pos, offset, bufEnd) != 0) {
+                return true;
+            }
+            pos++;
+        }
+        return false;
+    }
+
     /**
      * Returns a string representation of the object.
      *
diff --git a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java 
b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
index c3391898..a6fdf452 100644
--- a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
+++ b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
@@ -38,7 +38,6 @@ import org.apache.commons.text.matcher.StringMatcher;
 import org.apache.commons.text.matcher.StringMatcherFactory;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.MethodOrderer;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
@@ -284,7 +283,6 @@ public class StringSubstitutorTest {
      * Tests interpolation with weird boundary patterns.
      */
     @Test
-    @Disabled
     void testReplace_JiraText178_WeirdPatterns3() throws IOException {
         doReplace("${${a}", "$${${a}", false); // not "$${1" or "${1"
     }

Reply via email to