This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12095
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit c58f7c0e908b099e4c77383d75577f55408225c8
Author: Daniel Sun <[email protected]>
AuthorDate: Sat Jul 18 01:57:11 2026 +0900

    GROOVY-12095: Code in groovydoc does not display correctly
---
 .../tools/groovydoc/PreLanguageRewriter.java       |  29 ++-
 .../tools/groovydoc/SimpleGroovyClassDoc.java      |   9 +-
 .../groovy/tools/groovydoc/TagRenderer.java        | 162 +++++++++++--
 .../groovy-groovydoc/src/spec/doc/groovydoc.adoc   |  49 ++--
 .../groovy/tools/groovydoc/GroovyDocToolTest.java  |  15 +-
 .../tools/groovydoc/PreLanguageRewriterTest.groovy |  27 +++
 .../groovydoc/SimpleGroovyClassDocTests.groovy     | 268 +++++++++++++++++++++
 7 files changed, 509 insertions(+), 50 deletions(-)

diff --git 
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriter.java
 
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriter.java
index be8d8aa819..5fdefb9b66 100644
--- 
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriter.java
+++ 
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriter.java
@@ -22,6 +22,7 @@ import org.codehaus.groovy.runtime.ResourceGroovyMethods;
 
 import java.io.File;
 import java.io.IOException;
+import java.util.Locale;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -35,10 +36,11 @@ import java.util.regex.Pattern;
  * so Prism's highlighter picks them up (Prism only walks {@code <code>}
  * descendants of language-classed elements). A {@code <pre>} whose body
  * already contains a {@code <code>} element (e.g. the canonical form
- * emitted by {@code {@snippet}}) only receives the class on its opening
- * tag, to avoid nested {@code <code><code>}. Any {@code <pre>} with an
- * existing attribute — {@code class}, {@code id}, or other — is left
- * untouched.
+ * emitted by {@code {@snippet}}, or the uppercase {@code <CODE>} historically
+ * emitted by {@code {@code ...}}) only receives the class on its opening
+ * tag, to avoid nested {@code <code><CODE>} / {@code <code><code>}. Any
+ * {@code <pre>} with an existing attribute — {@code class}, {@code id}, or
+ * other — is left untouched.
  *
  * @since 6.0.0
  */
@@ -60,8 +62,8 @@ public final class PreLanguageRewriter {
      *       {@code <pre>} without a {@code <code>} child is skipped by the
      *       highlighter).</li>
      *   <li>All other cases — {@code <pre>} with an existing {@code <code>}
-     *       descendant, or with a class that doesn't mention
-     *       {@code language-*} — are left untouched.</li>
+     *       descendant (any letter case; GROOVY-12095), or with a class that
+     *       doesn't mention {@code language-*} — are left untouched.</li>
      * </ul>
      * No-op when {@code preLanguage} is {@code null} or empty.
      */
@@ -73,7 +75,11 @@ public final class PreLanguageRewriter {
         while (m.find()) {
             String attrs = m.group(1); // null or leading-whitespace-prefixed 
attribute run
             String body = m.group(2);
-            boolean hasCode = body.contains("<code");
+            // GROOVY-12095: HTML tag names are case-insensitive. TagRenderer's
+            // {@code {@code ...}} path historically emits <CODE>, while
+            // {@snippet} emits <code>. Treat either as an existing code child
+            // so we never nest <code><CODE>.
+            boolean hasCode = containsCodeTag(body);
             String replacement;
             if (attrs == null || attrs.trim().isEmpty()) {
                 // Bare <pre>: add class + wrap body in <code> (unless already 
present).
@@ -96,6 +102,15 @@ public final class PreLanguageRewriter {
         return sb.toString();
     }
 
+    /**
+     * {@code true} when {@code body} already contains a {@code <code} start
+     * tag in any letter case (HTML is case-insensitive for element names).
+     */
+    private static boolean containsCodeTag(String body) {
+        // Locale.ROOT so Turkish dotted-I does not break the "CODE" check.
+        return body.toLowerCase(Locale.ROOT).contains("<code");
+    }
+
     /**
      * Walks {@code dir} recursively and applies {@link #rewriteTags} to
      * every {@code .html} file in place. A no-op when {@code preLanguage}
diff --git 
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
 
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
index 9fdd2a51bc..9abe695421 100644
--- 
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
+++ 
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java
@@ -63,9 +63,14 @@ public class SimpleGroovyClassDoc extends 
SimpleGroovyAbstractableElementDoc imp
 
     // Retained for use by test helpers (SimpleGroovyClassDocTests) that pass 
it
     // to {@link #encodeAngleBracketsInTagBody}. No longer used internally — 
the
-    // tag-processing pipeline is now {@link TagRenderer}.
+    // tag-processing pipeline is now {@link TagRenderer}, which uses
+    // brace-balanced parsing (GROOVY-12095). This pattern stops at the first
+    // `}` and therefore does not match {@code {@code ...}} bodies that contain
+    // nested braces; prefer {@link TagRenderer} for full fidelity.
     /**
-     * Pattern used to locate {@code {@code ...}} style inline tags for 
angle-bracket escaping.
+     * Pattern used to locate simple {@code {@code ...}} style inline tags for
+     * angle-bracket escaping. Bodies with nested braces are out of scope for
+     * this regex; see {@link TagRenderer} for brace-balanced rendering.
      */
     public static final Pattern CODE_REGEX    = 
Pattern.compile("(?m)[{]@(code)\\s+([^}]*)}");
 
diff --git 
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
 
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
index 8eaa1c59db..181084abc8 100644
--- 
a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
+++ 
b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java
@@ -58,6 +58,16 @@ import java.util.regex.PatternSyntaxException;
  * as {@code <pre><code>} with language/id/class attributes and
  * HTML-escaped body).
  *
+ * <p>Inline-tag bodies use brace-balanced parsing (GROOVY-12095): the close
+ * of {@code {@code ...}} / {@code {@literal ...}} / {@code {@snippet ...}}
+ * / etc. is the {@code '}'} that balances the opening {@code '{'}, so nested
+ * braces in Groovy closures and map/type literals are retained. Matching is
+ * a linear character scan that also skips double- and single-quoted
+ * string/character literals (with backslash escapes) and {@code /* ... 
*}{@code /}
+ * block comments, so examples such as {@code System.out.println(" } ")} do
+ * not terminate the tag early. Line comments are intentionally <em>not</em>
+ * ignored so a single-line tag closer after {@code //} still works.
+ *
  * <p>Supported block tags: {@code @see}, {@code @param}, {@code @return},
  * {@code @throws} / {@code @exception}, {@code @since}, {@code @author},
  * {@code @version}, {@code @default}, plus synthesized {@code typeparam}
@@ -304,8 +314,11 @@ final class TagRenderer {
             return renderSnippetAt(text, start, nameEnd, out, links, relPath, 
rootDoc, classDoc);
         }
         int bodyStart = skipWhitespace(text, nameEnd);
-        // Match the previous regex's laxness: body extends to the first '}'.
-        int close = text.indexOf('}', bodyStart);
+        // GROOVY-12095: brace-balanced close so nested `{`/`}` inside
+        // {@code ...} / {@literal ...} (and other inline tags) do not
+        // terminate the tag early; string/char literals and block comments
+        // are skipped by the linear scanner.
+        int close = findMatchingCloseBrace(text, start);
         if (close < 0) return 0;
         String body = text.substring(bodyStart, close);
         renderInlineTag(name, body, out, links, relPath, rootDoc, classDoc, 
memberDoc, cfg, inheritDocVisited, inheritDocContext);
@@ -394,22 +407,12 @@ final class TagRenderer {
         int endPos;
         if (text.charAt(i) == ':') {
             int bodyStart = i + 1;
-            // Brace-balanced body — consume until the matching close of the
-            // outer {@snippet ...}.
-            int depth = 1;
-            int j = bodyStart;
-            while (j < n && depth > 0) {
-                char c = text.charAt(j);
-                if (c == '{') depth++;
-                else if (c == '}') {
-                    depth--;
-                    if (depth == 0) break;
-                }
-                j++;
-            }
-            if (depth != 0) return 0; // unterminated
-            body = text.substring(bodyStart, j);
-            endPos = j + 1; // past the final '}'
+            // Brace-balanced body (literal-aware linear scan) — consume until
+            // the matching close of the outer {@snippet ...}.
+            int close = findMatchingCloseBrace(text, start);
+            if (close < 0) return 0; // unterminated
+            body = text.substring(bodyStart, close);
+            endPos = close + 1; // past the final '}'
         } else {
             // External form: {@snippet file="..." [region="..."]}
             // Read the referenced file from the current package's
@@ -1450,8 +1453,129 @@ final class TagRenderer {
         int nameStart = start + 2; // past '{@'
         int nameEnd = readTagName(text, nameStart);
         if (nameEnd == nameStart) return start;
-        int close = text.indexOf('}', nameEnd);
+        // GROOVY-12095: must balance braces (and ignore string literals) so a
+        // `}` inside {@code { ... }} or {@code println(" } ")} does not end
+        // the surrounding block-tag body early.
+        int close = findMatchingCloseBrace(text, start);
         if (close < 0) return start;
         return close + 1;
     }
+
+    /**
+     * Returns the index of the {@code '}'} that balances the {@code '{'} at
+     * {@code openPos}, or {@code -1} if braces are unbalanced / truncated.
+     * <p>
+     * Linear O(n) character scan: depth starts at 1 for the opening brace,
+     * increments on each structural {@code '{'}, decrements on each structural
+     * {@code '}'}, and the match is complete when depth returns to zero.
+     * While scanning, the following spans are skipped so braces inside them
+     * do not affect depth:
+     * </p>
+     * <ul>
+     *   <li>double-quoted string literals with backslash escapes
+     *       ({@code System.out.println(" } ")}, GString {@code 
"value=${x}"})</li>
+     *   <li>single-quoted string/character literals ({@code char c = 
'}'})</li>
+     *   <li>block comments ({@code /* ... *}{@code /})</li>
+     * </ul>
+     * <p>
+     * Line comments ({@code // ...}) are <em>not</em> skipped: a closer on the
+     * same line as a trailing comment in a single-line tag
+     * (e.g. {@code {@code x // note}}) must still terminate the tag.
+     * </p>
+     *
+     * @param text    source text
+     * @param openPos index of the opening {@code '{'}
+     * @return index of the matching close brace, or {@code -1}
+     */
+    private static int findMatchingCloseBrace(String text, int openPos) {
+        if (openPos < 0 || openPos >= text.length() || text.charAt(openPos) != 
'{') {
+            return -1;
+        }
+        int depth = 1;
+        final int n = text.length();
+        int j = openPos + 1;
+        while (j < n) {
+            char c = text.charAt(j);
+            if (c == '"') {
+                j = skipDoubleQuotedLiteral(text, j, n);
+                continue;
+            }
+            if (c == '\'') {
+                j = skipSingleQuotedLiteral(text, j, n);
+                continue;
+            }
+            if (c == '/' && j + 1 < n && text.charAt(j + 1) == '*') {
+                j = skipBlockComment(text, j, n);
+                continue;
+            }
+            if (c == '{') {
+                depth++;
+            } else if (c == '}') {
+                depth--;
+                if (depth == 0) {
+                    return j;
+                }
+            }
+            j++;
+        }
+        return -1;
+    }
+
+    /**
+     * Advances past a double-quoted literal starting at {@code quotePos}.
+     * Returns the index of the character after the closing {@code '"'}, or
+     * {@code n} if the quote is unterminated (remaining input treated as
+     * inside the literal, so braces there do not affect depth).
+     */
+    private static int skipDoubleQuotedLiteral(String text, int quotePos, int 
n) {
+        int j = quotePos + 1;
+        while (j < n) {
+            char c = text.charAt(j);
+            if (c == '\\') {
+                j += 2; // skip escaped char (may go past n; loop condition 
handles it)
+                continue;
+            }
+            if (c == '"') {
+                return j + 1;
+            }
+            j++;
+        }
+        return n;
+    }
+
+    /**
+     * Advances past a single-quoted string/character literal starting at
+     * {@code quotePos}. Same contract as {@link #skipDoubleQuotedLiteral}.
+     */
+    private static int skipSingleQuotedLiteral(String text, int quotePos, int 
n) {
+        int j = quotePos + 1;
+        while (j < n) {
+            char c = text.charAt(j);
+            if (c == '\\') {
+                j += 2;
+                continue;
+            }
+            if (c == '\'') {
+                return j + 1;
+            }
+            j++;
+        }
+        return n;
+    }
+
+    /**
+     * Advances past a {@code /* ... *}{@code /} block comment starting at
+     * {@code slashPos} (the {@code '/'}). Returns the index after
+     * {@code *}/{@code}, or {@code n} if unterminated.
+     */
+    private static int skipBlockComment(String text, int slashPos, int n) {
+        int j = slashPos + 2; // past /*
+        while (j + 1 < n) {
+            if (text.charAt(j) == '*' && text.charAt(j + 1) == '/') {
+                return j + 2;
+            }
+            j++;
+        }
+        return n;
+    }
 }
diff --git a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc 
b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
index 9fab57b12b..c955a0e9c1 100644
--- a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
+++ b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
@@ -166,10 +166,16 @@ Inline tags appear within a comment body and are expanded 
in place.
 
 |`{@code <text>}`
 |Renders the text in a monospaced code style. HTML metacharacters are
-escaped.
+escaped. The body is brace-balanced with a linear scan (GROOVY-12095),
+so nested braces — Groovy closures, map/type literals, etc. — are kept
+intact, including inside `<pre>{@code ...}</pre>` examples. Braces
+inside double- or single-quoted string/character literals
+(e.g. `System.out.println(" } ")`) and `/* ... */` block comments are
+ignored while matching.
 
 |`{@literal <text>}`
-|Renders the text verbatim without code styling.
+|Renders the text verbatim without code styling. Same brace-balanced
+body rules as `{@code}`.
 
 |`{@value #FIELD}`, `{@value Class#FIELD}`
 |Inlines the value of a compile-time-constant field. Same-class
@@ -513,36 +519,41 @@ matching closing brace is the snippet body.
 
 ==== Brace balancing in the inline form
 
-Body parsing is a simple brace counter: the parser starts at depth 1 on
-the `{` after `{@snippet`, increments on every `{` it sees, decrements on
-every `}`, and the matching close is reached when depth returns to zero.
-The counter has *no awareness* of string literals, character escapes, or
-comments, so every `{` in the body must have a matching `}`, including
-braces inside GString interpolations and string literals. Code like
-`"${x}"` or `catalog.each { book -> ... }` is fine — GString `${...}`
-blocks and closure bodies already balance. A lone `{` or `}` inside a
-plain string, however, will confuse the parser:
+Body parsing is a linear brace-depth scan: depth starts at 1 on the
+opening `{` of the tag, increments on each structural `{`, decrements
+on each structural `}`, and the matching close is reached when depth
+returns to zero. Double- and single-quoted string/character literals
+(backslash escapes included) and `/* ... */` block comments are skipped
+while scanning, so braces inside them do not affect depth. Nested
+braces in real code — closures, `catalog.each { book -> ... }`,
+GString `"value=${x}"`, and unbalanced braces *inside* quotes such as
+`System.out.println(" } ")` — are therefore handled correctly and do
+not terminate the tag early.
 
 [source,groovy]
 ----
 /**
  * {@snippet lang="groovy" :
- * println '}'    // <-- stray literal } terminates the snippet body here
+ * System.out.println(" } ")   // } inside the string is fine
+ * if (ok) { work() }
  * }
  */
 ----
 
-Three workarounds when an example really needs an unbalanced literal
-brace:
+Line comments (`// ...`) are *not* ignored while matching, so a
+single-line tag closer after a trailing comment still works
+(`{@code x // note}`). Braces that are not inside a quoted literal and
+are not balanced (e.g. a raw `}` with no matching `{` outside any
+string) will still confuse the parser; workarounds:
 
-* Rewrite the example so all braces balance (often trivially possible,
-  e.g. `println '${"}"}'` or a string-concatenation alternative).
+* Rewrite the example so structural braces balance, or keep unbalanced
+  braces inside quotes (already ignored).
 * Move the sample into a file and use the external form
   (`{@snippet file="Example.groovy"}`), which reads the file verbatim
   without brace-balanced parsing.
-* Write the `}` as the HTML entity `&#125;`, which groovydoc counts as a
-  single non-brace character during body extraction but renders as `}`
-  in the output.
+* Write a problematic `}` as the HTML entity `&#125;`, which is not a
+  brace character during body extraction but renders as `}` in the
+  output.
 
 ==== External file reference
 
diff --git 
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
 
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
index 95f8d12ff2..76f648a3c2 100644
--- 
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
+++ 
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java
@@ -488,9 +488,18 @@ public class GroovyDocToolTest extends GroovyTestCase {
         assertNotNull(doc);
         assertTrue("Parent description should be inherited into child in:\n" + 
doc,
                 doc.contains("Parent-level description"));
-        // The literal `{@inheritDoc}` must not appear anymore.
-        assertFalse("{@inheritDoc} should be substituted, not left literal 
in:\n" + doc,
-                doc.contains("{@inheritDoc}"));
+        // Method-level {@inheritDoc} must be substituted. Restrict the check 
to
+        // the method detail section: the class-level narrative intentionally
+        // mentions the tag name inside {@code {@inheritDoc}}, and after
+        // GROOVY-12095 that nested form is rendered as 
<CODE>{@inheritDoc}</CODE>
+        // (brace-balanced {@code} body) rather than being truncated.
+        int methodDetail = doc.indexOf("transform(java.lang.String)");
+        assertTrue("Expected transform method detail section in:\n" + doc, 
methodDetail >= 0);
+        String methodSection = doc.substring(methodDetail);
+        assertTrue("Parent description should appear in the method detail 
section in:\n" + methodSection,
+                methodSection.contains("Parent-level description"));
+        assertFalse("{@inheritDoc} should be substituted in method docs, not 
left literal in:\n" + methodSection,
+                methodSection.contains("{@inheritDoc}"));
     }
 
     public void testInheritDocSubstitutesMatchingParentBlockTagsInGroovy() 
throws Exception {
diff --git 
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriterTest.groovy
 
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriterTest.groovy
index e234c3de3e..d4681438df 100644
--- 
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriterTest.groovy
+++ 
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/PreLanguageRewriterTest.groovy
@@ -47,6 +47,33 @@ class PreLanguageRewriterTest {
         assert out == '<pre class="language-groovy"><code 
class="language-groovy">assert 1 == 1</code></pre>'
     }
 
+    /**
+     * GROOVY-12095: TagRenderer historically wraps {@code {@code ...}} in
+     * uppercase {@code <CODE>}. HTML tag names are case-insensitive, so that
+     * already supplies a {@code <code>} descendant for Prism — do not nest a
+     * second lowercase {@code <code>} wrapper around it.
+     */
+    @Test
+    void testPreWithUppercaseCodeChildOnlyGetsClassOnPre() {
+        String input = '<pre><CODE>def x = { it }</CODE></pre>'
+        String out = PreLanguageRewriter.rewriteTags(input, 'groovy')
+        assert out == '<pre class="language-groovy"><CODE>def x = { it 
}</CODE></pre>'
+        assert !out.contains('<code><CODE>')
+    }
+
+    @Test
+    void testPreWithMixedCaseCodeChildOnlyGetsClassOnPre() {
+        String input = '<pre><Code class="x">body</Code></pre>'
+        String out = PreLanguageRewriter.rewriteTags(input, 'groovy')
+        assert out == '<pre class="language-groovy"><Code 
class="x">body</Code></pre>'
+    }
+
+    @Test
+    void testLanguageClassPreWithUppercaseCodeLeftAlone() {
+        String input = '<pre class="language-sql"><CODE>select 1</CODE></pre>'
+        String out = PreLanguageRewriter.rewriteTags(input, 'groovy')
+        assert out == input
+    }
     @Test
     void testPreWithExistingClassIsNotRewritten() {
         String input = '<pre class="groovyTestCase">assert 2 == 2</pre>'
diff --git 
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDocTests.groovy
 
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDocTests.groovy
index f10189c0df..0acfcae347 100644
--- 
a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDocTests.groovy
+++ 
b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDocTests.groovy
@@ -50,6 +50,274 @@ class SimpleGroovyClassDocTests {
         assert result == 'text with code <CODE>A&lt;B&gt;C</CODE> tag'
     }
 
+    /**
+     * GROOVY-12095: {@code {@code ...}} must use brace-balanced body parsing 
so
+     * nested braces (e.g. Groovy closures) do not terminate the tag early.
+     */
+    @Test
+    void testReplaceTags_codeWithNestedBraces() {
+        def text = '''\
+            <pre>{@code
+            def results = AsyncScope.withScope { scope ->
+                def userTask  = scope.async { fetchUser(id) }
+                def orderTask = scope.async { fetchOrders(id) }
+                return [user: await(userTask), orders: await(orderTask)]
+            }
+            // Both tasks guaranteed complete here
+            }</pre>
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.startsWith('<pre><CODE>')
+        assert result.endsWith('</CODE></pre>\n') || 
result.endsWith('</CODE></pre>')
+        assert !result.contains('</CODE>\n') || result.indexOf('</CODE>') == 
result.lastIndexOf('</CODE>')
+        // Full body retained through the outer closing brace of withScope { 
... }
+        assert result.contains('scope.async { fetchUser(id) }')
+        assert result.contains('scope.async { fetchOrders(id) }')
+        assert result.contains('// Both tasks guaranteed complete here')
+        // Single CODE wrap — the matching } of {@code ...} is the last one
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        // Angle brackets / arrows escaped inside the code body
+        assert result.contains('scope -&gt;')
+    }
+
+    @Test
+    void testReplaceTags_codeWithNestedBracesInline() {
+        def text = 'maps like {@code Map{K, V}} and closures {@code { x -> x * 
2 }}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == 'maps like <CODE>Map{K, V}</CODE> and closures 
<CODE>{ x -&gt; x * 2 }</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_literalWithNestedBraces() {
+        def text = 'literal {@literal {a} and {b}} tag'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == 'literal {a} and {b} tag'
+    }
+
+    @Test
+    void testReplaceTags_codeInsideBlockTagWithNestedBraces() {
+        // skipInlineTag must also balance braces so a } inside {@code} does 
not
+        // end the surrounding block-tag body early.
+        def text = '''\
+            Description.
+            @param x a closure {@code { a -> a + 1 }} applied to the input
+            @return the result
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('<CODE>{ a -&gt; a + 1 }</CODE>')
+        assert result.contains('Parameters')
+        assert result.contains('Returns')
+        assert result.contains('applied to the input')
+    }
+
+    @Test
+    void testReplaceTags_codeDeeplyNestedBraces() {
+        def text = '{@code outer { mid { inner } } end}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>outer { mid { inner } } end</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_unbalancedCodeLeftLiteral() {
+        // Unterminated {@code ...} — no matching close brace — is emitted
+        // verbatim (renderer returns 0 and the tokenizer falls through).
+        def text = 'before {@code { still open after'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('{@code')
+        assert result.contains('still open')
+    }
+
+    @Test
+    void testReplaceTags_emptyCodeBody() {
+        def text = 'empty {@code} tag'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == 'empty <CODE></CODE> tag'
+    }
+
+    @Test
+    void testReplaceTags_codeThenSiblingText() {
+        // Early intentional close still works: first balanced pair ends the 
tag.
+        def text = '{@code {a}} trailing'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>{a}</CODE> trailing'
+    }
+
+    /**
+     * GROOVY-12095: braces inside double-quoted string literals must not
+     * terminate {@code {@code ...}}.
+     */
+    @Test
+    void testReplaceTags_codeWithBraceInsideDoubleQuotedString() {
+        def text = '{@code System.out.println(" } ")}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>System.out.println(" } ")</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeWithBraceInsideSingleQuotedString() {
+        def text = "{@code char c = '}'; return c}"
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == "<CODE>char c = '}'; return c</CODE>"
+    }
+
+    @Test
+    void testReplaceTags_codeWithOpenBraceInsideString() {
+        def text = '{@code System.out.println(" { ")}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>System.out.println(" { ")</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeWithGStringBracesInsideQuotes() {
+        def text = '{@code println "value=${x}"}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>println "value=${x}"</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeWithEscapedQuotesAndBraces() {
+        def text = '{@code s = "a \\" } \\" b"}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>s = "a \\" } \\" b"</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeMultilineWithStringBraceAndClosures() {
+        def text = '''\
+            {@code
+            System.out.println(" } ")
+            def results = withScope { scope ->
+                scope.async { work() }
+            }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        assert result.contains('System.out.println(" } ")')
+        assert result.contains('scope.async { work() }')
+        assert result.contains('scope -&gt;')
+    }
+
+    @Test
+    void testReplaceTags_codeLineCommentStillClosesTag() {
+        // Line comments are intentionally not ignored: a single-line tag
+        // closer after // must still terminate the tag.
+        def text = '{@code x // note about braces }'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>x // note about braces </CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeInsideBlockTagWithStringBrace() {
+        def text = '''\
+            Description.
+            @param x uses {@code System.out.println(" } ")} carefully
+            @return ok
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('<CODE>System.out.println(" } ")</CODE>')
+        assert result.contains('carefully')
+        assert result.contains('Parameters')
+        assert result.contains('Returns')
+    }
+
+    @Test
+    void testReplaceTags_snippetWithBraceInsideString() {
+        def text = '''\
+            {@snippet lang="groovy" :
+            System.out.println(" } ")
+            if (ok) { work() }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('<pre><code')
+        assert result.contains('System.out.println(" } ")')
+        assert result.contains('if (ok) { work() }')
+        assert !result.contains('{@snippet')
+        assert result.contains('</code></pre>')
+    }
+
+    @Test
+    void testReplaceTags_literalWithBraceInsideString() {
+        def text = '{@literal println(" } ")}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == 'println(" } ")'
+    }
+
+    @Test
+    void testReplaceTags_codeWithBraceInsideBlockComment() {
+        def text = '{@code int x = 1; /* brace } here */ return x}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>int x = 1; /* brace } here */ return x</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_emptyStringsDoNotConfuseMatcher() {
+        def text = '{@code s = ""; t = \'\'; u = " } "}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>s = ""; t = \'\'; u = " } "</CODE>'
+    }
+
+    /**
+     * GROOVY-12095: a multi-kilobyte string literal inside {@code {@code ...}}
+     * must be scanned in linear time without blowing the call stack.
+     */
+    @Test
+    void testReplaceTags_codeWithVeryLongStringLiteralDoesNotStackOverflow() {
+        def longPayload = 'x' * 50_000
+        def text = '{@code s = "' + longPayload + ' } more"; done}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.startsWith('<CODE>s = "')
+        assert result.endsWith('</CODE>')
+        assert result.contains(longPayload + ' } more')
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+    }
+
     @Test
     void testEncodeAngleBracketsInTagBody() {
         def text = 'text with <tag1> outside and {@code <tag2> inside} a @code 
tag'

Reply via email to