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")
     }
 

Reply via email to