Skip to content

Groovy: keep escape sequences decoded in J.Literal.value - #8872

Merged
jkschneider merged 1 commit into
mainfrom
groovy-j.literal-values-keep-escapes-unexpanded
Sep 19, 2026
Merged

jkschneider merged 1 commit into
mainfrom
groovy-j.literal-values-keep-escapes-unexpanded

Conversation

@knutwannheden

Copy link
Copy Markdown
Contributor

GroovyParserVisitor put the raw source spelling into J.Literal.value for every String literal, so a recipe reading getValue() on def s = "a\nb" got a backslash and an n where the newline should be. J.Literal documents value as the decoded value and valueSource as the spelling that gets printed.

The Groovy compiler has already decoded the literal, per delimiter, by the time the visitor sees it:

literal value valueSource
"a\nb" a⏎b "a\nb"
'a\nb' a⏎b 'a\nb'
'''a\nb''' a⏎b '''a\nb'''
/a\nb/ a\nb /a\nb/
$/a\nb/$ a\nb $/a\nb/$

So both sites — the ClassHelper.STRING_TYPE branch of visitConstantExpression, and the constant segments of visitGStringExpression — keep ConstantExpression.getValue() and take only valueSource from the source. There is no unescaper here, and none is needed. Printing is unaffected: JavaPrinter.visitLiteral emits getValueSource().

The callers that searched a spelling for its value

Several recipes derived a literal's delimiter with valueSource.substring(0, valueSource.indexOf(value)). A decoded value is not a substring of its own spelling, so indexOf returns -1:

ChangeExtraProperty("foo", "baz")  on  ext { foo = "a\nb" }

java.lang.StringIndexOutOfBoundsException: Range [0, -1) out of bounds for length 6

ChangeStringLiteral.withStringValue recognises the delimiters instead, matching a known open/close pair against both ends of the spelling. A pair only fits when the spelling encloses at least as many characters as the value has, since an escape sequence is longer than the character it spells.

That length test is what separates a delimited literal from a G.GString or K.StringTemplate fragment: a fragment's value includes the quotes or slashes it starts and ends with, so no pair fits and it is re-emitted bare. Recognising the pair also repairs delimiters the old search got wrong:

spelling before after
$/a/$ $/baz$/ $/baz/$
"" baz "baz"

ChangeDependencyClassifier and ChangeDependencyExtension repeated that search in six places and call the helper now. UpgradePluginVersion patched a spelling with String.replace and left it on the old version whenever the search failed; it calls the helper too.

Two callers that needed a spelling, not a value

DependencyUseStringNotation rebuilds map notation as a fresh double-quoted literal, which has no way to spell a quote, backslash or dollar that the value happens to contain — version: '$x' became a live interpolation. Such a component is now left alone.

FindRepository compares a GString's reconstructed spelling against its url option. Its TemplateAsString appends the template's delimiters itself, so a fragment of that template contributes its valueSource, while a literal nested inside an interpolation keeps contributing its value — that one is spelled by its own delimiters. Its two GString paths agree again.

Tests

LiteralTest pins the decoded value and the spelling at both parser sites, with a slashy string as the control that decoding stays delimiter-sensitive. ChangeStringLiteralTest covers the escaped-delimiter, fragment and dollar-slashy cases, and each affected recipe has a regression test for the input that broke it.

`GroovyParserVisitor` stored the raw source spelling in `J.Literal.value` for
String literals, so `"a\nb"` carried a backslash and an `n` rather than a
newline. `value` is the decoded value; `valueSource` is the spelling that gets
printed.

Both sites now keep `ConstantExpression.getValue()` and take only `valueSource`
from the source: `visitConstantExpression`'s `ClassHelper.STRING_TYPE` branch,
and the constant segments of `visitGStringExpression`. No unescaper is needed —
the compiler already decodes per delimiter, so `'a\nb'` and `"""a\nb"""` yield
a newline while `/a\nb/` and `$/a\nb/$` keep the backslash. Printing is
unaffected, as `JavaPrinter.visitLiteral` emits `getValueSource()`.

Callers that derived a literal's delimiter by searching its spelling for its
value could no longer find it. `ChangeExtraProperty` on `ext { foo = "a\nb" }`
threw:

    StringIndexOutOfBoundsException: Range [0, -1) out of bounds for length 6

`ChangeStringLiteral.withStringValue` recognizes the delimiters instead,
matching a known open/close pair at both ends of the spelling. A pair only fits
when the spelling encloses at least as many characters as the value has, since
an escape sequence is longer than the character it spells. That is what
separates a delimited literal from a `G.GString` or `K.StringTemplate`
fragment, whose value includes any quotes or slashes it starts and ends with:
a fragment matches no pair and is re-emitted bare.

`ChangeDependencyClassifier` and `ChangeDependencyExtension` repeated the
search in six places and route through that helper now, as does
`UpgradePluginVersion`, which patched a spelling with `String.replace` and left
it on the old version whenever the search failed.

Two more callers read a value where they needed a spelling.
`DependencyUseStringNotation` rebuilds map notation as a fresh double quoted
literal, which cannot spell a quote, backslash or dollar, so a component
holding one is left alone. `FindRepository` builds a GString's spelling to
compare against its `url` option, and takes a template's own fragments from
`valueSource` so that both of its GString paths agree.

Recognizing the pair also repairs delimiters the search got wrong: `$/a/$`
became `$/baz$/`, and an empty value lost its quotes entirely.
@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite Sep 14, 2026
@jkschneider
jkschneider merged commit 49f5a10 into main Sep 19, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Sep 19, 2026
@jkschneider
jkschneider deleted the groovy-j.literal-values-keep-escapes-unexpanded branch September 19, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants