From 6b2ff03946ba3bb218c4ee735376d5b31527d0fe Mon Sep 17 00:00:00 2001 From: simpleqt <2015900818@qq.com> Date: Wed, 4 Nov 2026 01:31:14 +0800 Subject: [PATCH] Honor the escape character immediately before a substituted range StringSubstitutor.substitute() decided whether a variable prefix was escaped by comparing the prefix position with the offset of the range being substituted rather than with the start of the builder: if (pos > offset && builder.charAt(pos - 1) == escapeCh) { offset is the start of the processed range, not the start of the builder. When a caller substitutes only part of a builder and the escape character is the character directly before that range, pos equals offset for the prefix, the test fails, and the escape is not recognised: new StringSubstitutor(map, "${", "}", '~') replaceIn(new TextStringBuilder("~${user}"), 0, 8) -> "${user}" replaceIn(new TextStringBuilder("~${user}"), 1, 7) -> "~fred" (wrong) The whole-string overloads pass offset 0, which is why they are not affected. An escape character is now honoured when it lies in the range being substituted or immediately before it, since the character directly before the range is the one that prefixes the range's first position. Behaviour for offset 0 is unchanged, an escape character that is not the variable's own escape is still left untouched, and an escape character inside the range behaves as before. Add StringSubstitutorTest.testSubstituteEscapeAtStartOfRange. The new test fails on master with "expected: <${user}> but was: <~fred>" and passes with this change. Assisted-By: DeepSeek Harness (AI agent), reviewed by the submitter Signed-off-by: simpleqt <2015900818@qq.com> --- .../commons/text/StringSubstitutor.java | 5 ++- .../commons/text/StringSubstitutorTest.java | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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");