Skip to content

Honor the escape character immediately before a substituted range - #788

Open
simpleqt wants to merge 1 commit into
apache:masterfrom
simpleqt:fix/StringSubstitutor-escape-at-range-start
Open

simpleqt wants to merge 1 commit into
apache:masterfrom
simpleqt:fix/StringSubstitutor-escape-at-range-start

Conversation

@simpleqt

@simpleqt simpleqt commented Nov 3, 2026

Copy link
Copy Markdown

Summary

StringSubstitutor ignores the escape character when the variable's escape character is the first character of the range passed to the offset/length overloads, so an escaped variable is substituted anyway.

Root cause

StringSubstitutor.substitute(TextStringBuilder, int offset, int length, List) decides whether a variable prefix is escaped with:

if (pos > offset && builder.charAt(pos - 1) == escapeCh) {

offset is the start of the range being processed, not the start of the builder. When a caller substitutes only part of a builder and the escape character is immediately before that range, pos == offset for the prefix, so the test fails and the escape is not recognised:

  • replaceIn(builder, 0, length) on ~${user} → ${user} (correct)
  • replaceIn(builder, 1, length - 1) on the same text → ~fred (escape ignored, variable substituted)

The whole-string overloads hide the bug because they pass offset = 0.

Reproduction

With new StringSubstitutor(map, "${", "}", '~') and map.put("user", "fred"):

TextStringBuilder b = new TextStringBuilder("~${user}");
sub.replaceIn(b, 1, b.length() - 1);
// before: "~fred"   expected: "${user}"

~ here is the escape character; the caller's intent is that ${user} is left literal, exactly as it is when the whole builder is processed.

Fix

The escape character is now honoured when it lies in the range being substituted or immediately before it, because the character directly before the range is the one that prefixes the range's first position:

final int escStart = offset > 0 ? offset - 1 : offset;
...
if (pos > escStart && builder.charAt(pos - 1) == escapeCh) {

This keeps the existing behaviour for offset == 0 (and for ranges shorter than one character), and makes substitution of a sub-range agree with substitution of the whole builder. 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.

Tests

Added StringSubstitutorTest.testSubstituteEscapeAtStartOfRange, which covers: the whole builder, the escape character immediately before the substituted range, the same followed by an unescaped variable, an escape character inside the range, and an unrelated escape character before the range that must be left alone.

Evidence on the new test:

  • Without the production change: expected: <${user}> but was: <~fred> — 80 tests run, 1 failure.
  • With the change: 80 tests run, 0 failures.

Full verification with the default mvn goal: 2045 tests, 0 failures; checkstyle, PMD and SpotBugs all pass.

Note: 6 tests in PathFenceResolverTest and FileStringLookupTest error on this Windows machine with IllegalArgumentException: 'other' has different root, because their write(Path, String) helper does CURRENT_PATH.toAbsolutePath().relativize(path.toAbsolutePath()) and the @TempDir is on C:\ while the working directory is on G:\. Those 6 errors are present on unmodified master and are unrelated to this change.

Jira

CONTRIBUTING.md asks for a Jira ticket, and RELEASE-NOTES.txt is generated from Jira. I do not have an Apache Jira account, so there is no ticket key to reference here; the commit message has no Jira key for that reason. Happy to update the commit message and add a RELEASE-NOTES.txt entry if a ticket is created and a key is provided.

AI disclosure

An AI agent assisted with investigating, implementing and testing this change, and with drafting this description. The change was reviewed and verified by the submitter.

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>
Copilot AI balanced review requested due to automatic review settings November 3, 2026 17:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants