You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2026/06/11 00:12:58 UTC

(commons-text) branch master updated: [TEXT-179] StringSubstitutor incorrectly removes the escape character in "$${${a}" (#752)

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 <ga...@users.noreply.github.com>
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"
     }