From 4a0091a1ad0a9254832da6ce7ee86d6e578fc429 Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Wed, 28 Oct 2026 10:26:59 +0530 Subject: [PATCH] Keep StringSubstitutor from treating a value's trailing $ as an escape After a variable is replaced, substitute resumes scanning at the end of the inserted value, and the escape check looks at the char before the next prefix bounded only by the scan offset. A value ending in the escape char therefore escaped the variable that follows it in the template: the value lost its last char and the next variable was left unresolved, e.g. price=5$ makes "${price}${x}" return "5${x}" instead of "5$X". Only accept an escape char at or after the end of the last substitution. Same change as commons-lang StrSubstitutor (#1834), with its regression tests ported over. --- .../commons/text/StringSubstitutor.java | 11 +++- .../commons/text/StringSubstitutorTest.java | 63 +++++++++++++++++++ 2 files changed, 73 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..4053488158 100644 --- a/src/main/java/org/apache/commons/text/StringSubstitutor.java +++ b/src/main/java/org/apache/commons/text/StringSubstitutor.java @@ -198,6 +198,12 @@ * "The variable $${${name}} must be used." * *

+ * An escape character at the end of a substituted value does not escape a following variable reference in the containing text. + * For example, if {@code price} is {@code 5$} and {@code item} is {@code X}, then {@code ${price}${item}} becomes {@code 5$X}. + * This also applies to default values and recursive substitutions. Escape characters before variable references within a value + * are processed when that value is recursively substituted. + *

+ *

* In some complex scenarios you might even want to perform substitution in the names of variables, for instance *

* @@ -1436,13 +1442,15 @@ private Result substitute(final TextStringBuilder builder, final int offset, fin int bufEnd = offset + length; int pos = offset; int escPos = -1; + // an escape character must come from the text being scanned, not from the end of a substituted value + int escapeStart = offset; outer: while (pos < bufEnd) { final int startMatchLen = prefixMatcher.isMatch(builder, pos, offset, bufEnd); if (startMatchLen == 0) { pos++; } else { // found variable start marker - if (pos > offset && builder.charAt(pos - 1) == escapeCh) { + if (pos > escapeStart && builder.charAt(pos - 1) == escapeCh) { // escape detected if (preserveEscapes) { // keep escape @@ -1536,6 +1544,7 @@ private Result substitute(final TextStringBuilder builder, final int offset, fin } change = change + varLen - (endPos - startPos); pos += change; + escapeStart = pos; bufEnd += change; lengthChange += change; } else if (undefinedVariableException) { diff --git a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java index a6fdf45297..de5235974f 100644 --- a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java +++ b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java @@ -834,6 +834,69 @@ void testReplaceUnknownShortestKeyOnlyExtraLast() throws IOException { assertEqualsCharSeq(expected, replace(new StringSubstitutor(values), expected)); } + /** + * Tests that an escape character at the end of a substituted value does not escape the next variable. + */ + @Test + void testReplaceValueEndingWithEscape() throws IOException { + values.put("price", "5$"); + doReplace("The price is 5$lazy dog.", "The price is ${price}${target}.", true); + values.put("animal", "$"); + doReplace("The $lazy dog jumps over the $${target}.", "The ${animal}${target} jumps over the ${animal}$${target}.", true); + } + + @Test + void testReplaceValueEndingWithEscapeCustomDelimiters() throws IOException { + values.put("price", "5!"); + final StringSubstitutor sub = new StringSubstitutor(values, "<", ">", '!'); + assertEquals("5!lazy dog", replace(sub, "")); + assertEquals("5!", replace(sub, "!")); + } + + @Test + void testReplaceValueEndingWithEscapeDefaultValue() throws IOException { + doReplace("The price is 5$lazy dog.", "The price is ${price:-5$}${target}.", true); + } + + @Test + void testReplaceValueEndingWithEscapeEmptyValue() throws IOException { + values.put("price", "5$"); + values.put("empty", ""); + doReplace("The price is 5$lazy dog.", "The price is ${price}${empty}${target}.", true); + } + + @Test + void testReplaceValueEndingWithEscapeInVariableName() throws IOException { + values.put("prefix", "animal$"); + values.put("suffix", "name"); + values.put("animal$name", "fox"); + final StringSubstitutor sub = new StringSubstitutor(values); + sub.setEnableSubstitutionInVariables(true); + assertEquals("fox", replace(sub, "${${prefix}${suffix}}")); + } + + @Test + void testReplaceValueEndingWithEscapePreserved() throws IOException { + values.put("price", "5$"); + values.put("escaped", "$${target}"); + final StringSubstitutor sub = new StringSubstitutor(values); + sub.setPreserveEscapes(true); + assertEquals("5$lazy dog", replace(sub, "${price}${target}")); + assertEquals("5$$${target}", replace(sub, "${price}$${target}")); + assertEquals("$${target}lazy dog", replace(sub, "${escaped}${target}")); + } + + @Test + void testReplaceValueEndingWithEscapeRecursive() throws IOException { + values.put("price", "5$"); + values.put("recursive", "${price}"); + values.put("inside", "${price}${target}"); + values.put("escaped", "$${target}"); + doReplace("The price is 5$lazy dog.", "The price is ${recursive}${target}.", true); + doReplace("The price is 5$lazy dog.", "The price is ${inside}.", true); + doReplace("The ${target}lazy dog.", "The ${escaped}${target}.", true); + } + /** * Tests simple key replace. */