diff --git a/src/main/java/org/apache/commons/text/StringSubstitutor.java b/src/main/java/org/apache/commons/text/StringSubstitutor.java index 9c24a6deba..9458154d70 100644 --- a/src/main/java/org/apache/commons/text/StringSubstitutor.java +++ b/src/main/java/org/apache/commons/text/StringSubstitutor.java @@ -1434,6 +1434,9 @@ private Result substitute(final TextStringBuilder builder, final int offset, fin boolean altered = false; int lengthChange = 0; int bufEnd = offset + length; + // An escape character is honored when it lies in the range being substituted, or immediately before it: the character just before the range is the + // one that prefixes the first position of the range, so dropping it there would substitute a variable the caller escaped. + final int escStart = offset > 0 ? offset - 1 : offset; int pos = offset; int escPos = -1; outer: while (pos < bufEnd) { @@ -1442,7 +1445,7 @@ private Result substitute(final TextStringBuilder builder, final int offset, fin pos++; } else { // found variable start marker - if (pos > offset && builder.charAt(pos - 1) == escapeCh) { + if (pos > escStart && builder.charAt(pos - 1) == escapeCh) { // escape detected if (preserveEscapes) { // keep escape diff --git a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java index a6fdf45297..8843827de6 100644 --- a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java +++ b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java @@ -1083,6 +1083,43 @@ void testSubstitutePreserveEscape() throws IOException { assertEqualsCharSeq("value $${escaped}", replace(sub, org)); } + /** + * An escape character that directly precedes the first position of the range being substituted must still escape the variable, whether the range starts + * at the beginning of the builder or not. + */ + @Test + void testSubstituteEscapeAtStartOfRange() { + final Map map = new HashMap<>(); + map.put("user", "fred"); + map.put("a", "A"); + final StringSubstitutor sub = new StringSubstitutor(map, "${", "}", '~'); + + // The whole builder. + final TextStringBuilder whole = new TextStringBuilder("~${user}"); + assertTrue(sub.replaceIn(whole)); + assertEqualsCharSeq("${user}", whole.toString()); + + // Only the escaped variable, so the escape character sits immediately before the substituted range. + final TextStringBuilder range = new TextStringBuilder("~${user}"); + assertTrue(sub.replaceIn(range, 1, range.length() - 1)); + assertEqualsCharSeq("${user}", range.toString()); + + // Same text, same result, when the escaped variable is followed by an unescaped one. + final TextStringBuilder rangeWithTail = new TextStringBuilder("~${a} and ${a}"); + assertTrue(sub.replaceIn(rangeWithTail, 1, rangeWithTail.length() - 1)); + assertEqualsCharSeq("${a} and A", rangeWithTail.toString()); + + // An escape character inside the range keeps working as before. + final TextStringBuilder inside = new TextStringBuilder("x~${user}"); + assertTrue(sub.replaceIn(inside, 1, inside.length() - 1)); + assertEqualsCharSeq("x${user}", inside.toString()); + + // An escape character before the range that is not the variable's own escape is left alone. + final TextStringBuilder unrelated = new TextStringBuilder("x~y${user}"); + assertTrue(sub.replaceIn(unrelated, 2, unrelated.length() - 2)); + assertEqualsCharSeq("x~yfred", unrelated.toString()); + } + @Test void testToString() { final StringSubstitutor s = new StringSubstitutor(null, "prefix", "suffix");