This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 97d68bb1c3 GROOVY-12153: Fix AstNodeToScriptAdapter decompiler
rendering fidelity for strings
97d68bb1c3 is described below
commit 97d68bb1c30b184c345979ed6e6523338a80994f
Author: Paul King <[email protected]>
AuthorDate: Sun Jul 12 17:09:29 2026 +1000
GROOVY-12153: Fix AstNodeToScriptAdapter decompiler rendering fidelity for
strings
---
.../console/ui/AstNodeToScriptAdapter.groovy | 90 +++++++++++++++++++++-
.../console/ui/AstNodeToScriptAdapterTest.groovy | 66 +++++++++++++++-
2 files changed, 151 insertions(+), 5 deletions(-)
diff --git
a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/AstNodeToScriptAdapter.groovy
b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/AstNodeToScriptAdapter.groovy
index 9a3f7e0257..55bd058417 100644
---
a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/AstNodeToScriptAdapter.groovy
+++
b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/AstNodeToScriptAdapter.groovy
@@ -937,9 +937,7 @@ class AstNodeToScriptVisitor implements
CompilationUnit.IPrimaryClassNodeOperati
@Override
void visitConstantExpression(ConstantExpression expression, boolean
unwrapQuotes = false) {
if (expression.value instanceof String && !unwrapQuotes) {
- // string reverse escaping is very naive
- def escaped = ((String) expression.value).replaceAll('\n',
'\\\\n').replaceAll("'", "\\\\'")
- print "'$escaped'"
+ print "'" + escapeSingleQuoted((String) expression.value) + "'"
} else {
print expression.value
// re-append the literal's type suffix so re-parsing yields the
same type
@@ -957,6 +955,36 @@ class AstNodeToScriptVisitor implements
CompilationUnit.IPrimaryClassNodeOperati
}
}
+ /**
+ * Escapes a String so it re-parses as a single-line, single-quoted Groovy
literal.
+ * The former approach only handled newlines and the single quote, so a
literal
+ * backslash (or a tab, carriage-return, form-feed, etc.) was emitted
verbatim,
+ * either changing the string's value or producing a script that no longer
compiles.
+ * '$' is intentionally left alone: it is inert inside single quotes.
+ */
+ private static String escapeSingleQuoted(String value) {
+ StringBuilder sb = new StringBuilder(value.length() + 8)
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i)
+ switch (c) {
+ case '\\' as char: sb.append('\\\\'); break // must be first
so we don't re-escape below
+ case '\'' as char: sb.append('\\\''); break
+ case '\n' as char: sb.append('\\n'); break
+ case '\r' as char: sb.append('\\r'); break
+ case '\t' as char: sb.append('\\t'); break
+ case '\b' as char: sb.append('\\b'); break
+ case '\f' as char: sb.append('\\f'); break
+ default:
+ if ((int) c < 0x20) {
+ sb.append(String.format('\\u%04x', (int) c))
+ } else {
+ sb.append(c)
+ }
+ }
+ }
+ return sb.toString()
+ }
+
/** Renders a class literal expression. */
@Override
void visitClassExpression(ClassExpression expression) {
@@ -997,7 +1025,61 @@ class AstNodeToScriptVisitor implements
CompilationUnit.IPrimaryClassNodeOperati
/** Renders a GString expression. */
@Override
void visitGStringExpression(GStringExpression expression) {
- print '"' + expression.text + '"'
+ // Rebuild from the node's parts rather than its verbatimText:
verbatimText holds the
+ // *decoded* text (so a literal '"', '\\' or '$' would break
re-parsing) and renders each
+ // embedded value via getText() (which is lossy, e.g. dropping
string-literal quotes).
+ // Escaping the text segments and re-visiting the values keeps the
emitted GString faithful.
+ List<ConstantExpression> strings = expression.strings
+ List<Expression> values = expression.values
+ print '"'
+ for (int i = 0; i < strings.size(); i++) {
+ String following = (String) strings[i].value
+ print escapeDoubleQuotedText(following)
+ if (i < values.size() && values[i] != null) {
+ Expression value = values[i]
+ String nextText = (i + 1 < strings.size()) ? (String)
strings[i + 1].value : ''
+ // '$name' short form is only safe when the following text
can't extend it into a
+ // different variable ('$nameX') or property ('$name.p');
otherwise use '${name}'.
+ if (value instanceof VariableExpression &&
!((VariableExpression) value).isThisExpression()
+ && (nextText.isEmpty() ||
!(Character.isJavaIdentifierPart(nextText.charAt(0)) || nextText.charAt(0) ==
'.' as char))) {
+ print '$' + ((VariableExpression) value).name
+ } else {
+ print '${'
+ printExpression value
+ print '}'
+ }
+ }
+ }
+ print '"'
+ }
+
+ /**
+ * Escapes a String so it survives re-parsing inside a double-quoted
GString's text segment.
+ * Beyond the usual control characters, '"' (delimiter), '\\' (escape) and
'$' (interpolation
+ * trigger) must all be escaped so literal text is never mistaken for
structure.
+ */
+ private static String escapeDoubleQuotedText(String value) {
+ StringBuilder sb = new StringBuilder(value.length() + 8)
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i)
+ switch (c) {
+ case '\\' as char: sb.append('\\\\'); break // must be first
+ case '"' as char: sb.append('\\"'); break
+ case '$' as char: sb.append('\\$'); break
+ case '\n' as char: sb.append('\\n'); break
+ case '\r' as char: sb.append('\\r'); break
+ case '\t' as char: sb.append('\\t'); break
+ case '\b' as char: sb.append('\\b'); break
+ case '\f' as char: sb.append('\\f'); break
+ default:
+ if ((int) c < 0x20) {
+ sb.append(String.format('\\u%04x', (int) c))
+ } else {
+ sb.append(c)
+ }
+ }
+ }
+ return sb.toString()
}
/** Renders a spread expression. */
diff --git
a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy
b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy
index a975e7be26..ad76afd49f 100644
---
a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy
+++
b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy
@@ -62,6 +62,69 @@ final class AstNodeToScriptAdapterTest {
assert result.contains("_out.toString().endsWith('\\n\\n')")
}
+ @Test
+ void testStringEscapingHandlesBackslashesQuotesAndControlChars() {
+ // Dollar-slashy input keeps these readable: inside it '\\' is a
literal backslash and
+ // '\n'/'\t' are literal backslash-letter pairs, so the *compiler*
turns them into a
+ // single backslash / a real newline / a real tab in each constant's
value.
+ String script = $/
+ def backslash = 'back\\slash'
+ def winPath = 'C:\\Users\\name'
+ def quoted = 'quote\'inside'
+ def tabbed = 'tab\there'
+ def newlined = 'multi\nline'
+ /$
+ String result = compileToScript(script)
+
+ // Each value must be re-emitted in a form that parses back to the
same value.
+ assert result.contains($/'back\\slash'/$) // literal backslash
-> \\
+ assert result.contains($/'C:\\Users\\name'/$)
+ assert result.contains($/'quote\'inside'/$) // single quote -> \'
+ assert result.contains($/'tab\there'/$) // real tab -> \t
+ assert result.contains($/'multi\nline'/$) // real newline -> \n
+
+ // The old naive escaping emitted a bare backslash (e.g.
'back\slash'), which either
+ // changes the value or fails to compile; the emitted script must
parse cleanly.
+ assert new GroovyShell().parse(result)
+ }
+
+ @Test
+ void testGStringRoundTripsToSameValue() {
+ // Each snippet ends in a GString; both the original and the adapter's
reconstruction
+ // must evaluate to the same value. These are dollar-slashy literals,
which themselves
+ // interpolate, so a literal '$' meant for the *snippet* is written
'$$' and '\' is kept
+ // verbatim (dollar-slashy has no backslash escapes).
+ def snippets = [
+ 'plain interpolation' : $/def name = 'Bob'; "hi $$name"/$,
+ 'interp string const' : $/"$${'y'}"/$,
+ 'double-quote inside' : $/def n = 'X'; "say \"hi\" to $$n"/$,
+ 'backslash / win path' : $/def f = 'F'; "C:\\tmp $$f"/$,
+ 'literal dollar' : $/def item = 'I'; "price \$$5 for
$$item"/$,
+ 'escaped interpolation' : $/def real = 'R'; "\$${notInterp} and
$$real"/$,
+ 'string args in interp' : $/"n=$${['a', 'b'].join(',')}"/$,
+ 'slashy regex gstring' : $/def c = 5; /\d+ = $$c//$,
+ 'lazy closure interp' : $/"$${-> 'lz'}"/$,
+ 'list-in-interp' : $/def a = 'A'; "items $${['x', 'y']}"/$,
+ 'multi-line triple' : $/def v = 'V'; """a
+b $$v"""/$,
+ ]
+
+ snippets.each { name, src ->
+ def expected = new GroovyShell().evaluate(src)
+ // free-form output (showScriptClass = false) so the
reconstruction is directly evaluable
+ String rebuilt = new AstNodeToScriptAdapter().compileToScript(
+ src, CompilePhase.SEMANTIC_ANALYSIS.phaseNumber, null,
true, false)
+ def actual
+ try {
+ actual = new GroovyShell().evaluate(rebuilt)
+ } catch (Throwable t) {
+ assert false, "[$name] reconstruction did not evaluate:
${t.message}\n--- rebuilt ---\n$rebuilt"
+ }
+ assert expected?.toString() == actual?.toString(),
+ "[$name] value changed\n expected:
${expected?.toString()?.inspect()}\n actual:
${actual?.toString()?.inspect()}\n--- rebuilt ---\n$rebuilt"
+ }
+ }
+
@Test
void testPackage() {
String script = ''' package foo.bar
@@ -911,7 +974,8 @@ final class AstNodeToScriptAdapterTest {
'''
String result = compileToScript(script)
- assert result.contains("assert 'abc.def' =~ '[a-z]b[a-z]\\.def' :
null")
+ // the backslash in the value is now properly escaped so the literal
round-trips
+ assert result.contains("assert 'abc.def' =~ '[a-z]b[a-z]\\\\.def' :
null")
assert result.contains("assert 'cheesecheese' =~ 'cheese' : null")
}