Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/main/java/org/apache/commons/text/StringSubstitutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/test/java/org/apache/commons/text/StringSubstitutorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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");
Expand Down