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 306153716e1eedc40cc8298dbdd16b22fab4646a
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        | 202 ++++++++-
 .../groovy-groovydoc/src/spec/doc/groovydoc.adoc   |  56 ++-
 .../groovy/tools/groovydoc/GroovyDocToolTest.java  |  15 +-
 .../tools/groovydoc/PreLanguageRewriterTest.groovy |  27 ++
 .../groovydoc/SimpleGroovyClassDocTests.groovy     | 496 +++++++++++++++++++++
 7 files changed, 784 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..8f7cfa6a7d 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 quoted string/character literals
+ * (including multi-line {@code """..."""} / {@code '''...'''} text blocks),
+ * with backslash escapes, {@code /* ... *}{@code /} block comments, and
+ * {@code //} line comments (to end of line), so braces inside strings, text
+ * blocks, or comments do not terminate the tag early.
+ *
  * <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,169 @@ 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/text-block
+        // literals) so a `}` inside {@code { ... }}, {@code println(" } ")},
+        // or {@code s = """ } """} does not end the surrounding block-tag 
body.
+        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>triple-quoted text blocks {@code """..."""} / {@code '''...'''}
+     *       (may span lines; e.g. {@code """ } """}, {@code '''\n}\n'''})</li>
+     *   <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>
+     *   <li>line comments ({@code //} to end of line, so a brace after
+     *       {@code //} does not affect depth)</li>
+     * </ul>
+     *
+     * @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 == '"' || c == '\'') {
+                // Prefer triple-quote text blocks over ordinary quoted 
literals
+                // so `""" } """` / multi-line bodies are not truncated at the
+                // first lone `}` inside the block.
+                if (isTripleQuoteAt(text, j, n, c)) {
+                    j = skipTripleQuotedLiteral(text, j, n, c);
+                } else {
+                    j = skipQuotedLiteral(text, j, n, c);
+                }
+                continue;
+            }
+            if (c == '/' && j + 1 < n) {
+                char next = text.charAt(j + 1);
+                if (next == '*') {
+                    j = skipBlockComment(text, j, n);
+                    continue;
+                }
+                if (next == '/') {
+                    // Skip // ... to end of line so `// }` does not close the 
tag.
+                    j = skipLineComment(text, j, n);
+                    continue;
+                }
+            }
+            if (c == '{') {
+                depth++;
+            } else if (c == '}') {
+                depth--;
+                if (depth == 0) {
+                    return j;
+                }
+            }
+            j++;
+        }
+        return -1;
+    }
+
+    /** {@code true} when {@code text[pos..pos+2]} is three copies of {@code 
quote}. */
+    private static boolean isTripleQuoteAt(String text, int pos, int n, char 
quote) {
+        return pos + 2 < n
+                && text.charAt(pos) == quote
+                && text.charAt(pos + 1) == quote
+                && text.charAt(pos + 2) == quote;
+    }
+
+    /**
+     * Advances past a triple-quoted text block ({@code """..."""} or
+     * {@code '''...'''}) starting at {@code openPos} (the first quote of the
+     * opening delimiter). The body may span lines. Returns the index after the
+     * closing triple quote, or {@code n} if unterminated.
+     */
+    private static int skipTripleQuotedLiteral(String text, int openPos, int 
n, char quote) {
+        int j = openPos + 3; // past opening """
+        while (j + 2 < n) {
+            char c = text.charAt(j);
+            if (c == '\\') {
+                // Skip escape pair so e.g. \""" does not close early.
+                j += 2;
+                continue;
+            }
+            if (c == quote && text.charAt(j + 1) == quote && text.charAt(j + 
2) == quote) {
+                return j + 3;
+            }
+            j++;
+        }
+        return n;
+    }
+
+    /**
+     * Advances past an ordinary single- or double-quoted literal starting at
+     * {@code quotePos}. Returns the index after the closing quote, or
+     * {@code n} if unterminated (remaining input treated as inside the
+     * literal, so braces there do not affect depth).
+     */
+    private static int skipQuotedLiteral(String text, int quotePos, int n, 
char quote) {
+        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 == quote) {
+                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;
+    }
+
+    /**
+     * Advances past a {@code //} line comment starting at {@code slashPos}
+     * (the first {@code '/'}). Consumes through the last character of the
+     * comment body, stopping <em>before</em> the line terminator ({@code \n}
+     * or {@code \r}) so the outer scanner still sees the newline. Returns
+     * {@code n} when the comment runs to end of input (no newline).
+     */
+    private static int skipLineComment(String text, int slashPos, int n) {
+        int j = slashPos + 2; // past //
+        while (j < n) {
+            char c = text.charAt(j);
+            if (c == '\n' || c == '\r') {
+                return j;
+            }
+            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..8cb45ef9a3 100644
--- a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
+++ b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
@@ -166,10 +166,17 @@ 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 ordinary quoted string/character literals
+(e.g. `System.out.println(" } ")`), multi-line text blocks
+(`"""..."""` / `'''...'''`), `/* ... */` block comments, and `// ...`
+line comments (e.g. `// }`) 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 +520,47 @@ 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. While scanning, ordinary quoted string/character
+literals (backslash escapes included), multi-line text blocks
+(`"""..."""` / `'''...'''`), `/* ... */` block comments, and `// ...`
+line comments (to end of line) are skipped 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, text blocks, or comments such as
+`System.out.println(" } ")`, `"""\n}\n"""`, or `// }` — 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
+ * // } inside a line comment is fine too
+ * def block = """
+ *   multi-line with }
+ * """
+ * if (ok) { work() }
  * }
  */
 ----
 
-Three workarounds when an example really needs an unbalanced literal
-brace:
+Because `//` comments run to end of line, put the tag-closing `}` on its
+own line (or after real code), not after a trailing `//` on the same
+line — otherwise the closer is swallowed by the comment. Braces that are
+not inside a skipped span and are not balanced 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, text blocks, or comments (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..1688c32ab6 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,502 @@ 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;')
+    }
+
+    /**
+     * GROOVY-12095: braces inside {@code //} line comments must not terminate
+     * {@code {@code ...}}.
+     */
+    @Test
+    void testReplaceTags_codeWithBraceInsideLineComment() {
+        def text = '''\
+            {@code
+            int x = 1 // }
+            def y = { it }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        assert result.contains('int x = 1 // }')
+        assert result.contains('def y = { it }')
+    }
+
+    @Test
+    void testReplaceTags_codeWithStandaloneLineCommentBrace() {
+        def text = '''\
+            {@code
+            // }
+            done()
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        assert result.contains('// }')
+        assert result.contains('done()')
+        // Structural closer is the final `}`, not the one in the line comment.
+        assert result.indexOf('// }') < result.indexOf('done()')
+        assert result.indexOf('done()') < result.lastIndexOf('</CODE>')
+    }
+
+    @Test
+    void 
testReplaceTags_codeWithLineCommentBraceOnSameLineAsCloserIsNotClosed() {
+        // The only `}` sits inside `// ...`, so there is no structural closer.
+        def text = '{@code x // note about braces }'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('{@code')
+        assert result.contains('note about braces')
+    }
+
+    @Test
+    void testReplaceTags_snippetWithBraceInsideLineComment() {
+        def text = '''\
+            {@snippet lang="groovy" :
+            // }
+            if (ok) { work() }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('<pre><code')
+        assert result.contains('// }')
+        assert result.contains('if (ok) { work() }')
+        assert !result.contains('{@snippet')
+        assert result.contains('</code></pre>')
+    }
+
+    @Test
+    void testReplaceTags_codeInsideBlockTagWithLineCommentBrace() {
+        def text = '''\
+            Description.
+            @param x uses {@code
+            // }
+            f(x)
+            } carefully
+            @return ok
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('// }')
+        assert result.contains('f(x)')
+        assert result.contains('carefully')
+        assert result.contains('Parameters')
+        assert result.contains('Returns')
+    }
+
+    @Test
+    void testReplaceTags_codeLineCommentDoesNotConsumeNextLine() {
+        // After // comment ends at newline, braces on the next line still 
count.
+        def text = '''\
+            {@code
+            // ignored }
+            { real }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        assert result.contains('// ignored }')
+        assert result.contains('{ real }')
+    }
+
+    @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
+    }
+
+    /**
+     * GROOVY-12095: braces inside triple-double-quote text blocks must not
+     * terminate {@code {@code ...}}.
+     */
+    @Test
+    void testReplaceTags_codeWithBraceInsideDoubleQuoteTextBlock() {
+        def text = '{@code s = """ } """; done}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>s = """ } """; done</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeWithBraceInsideSingleQuoteTextBlock() {
+        def text = "{@code s = ''' } '''; done}"
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == "<CODE>s = ''' } '''; done</CODE>"
+    }
+
+    @Test
+    void 
testReplaceTags_codeWithMultilineDoubleQuoteTextBlockContainingBrace() {
+        def text = '''\
+            {@code
+            def s = """
+              multi-line with }
+              and more
+            """
+            def x = { it }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        assert result.contains('multi-line with }')
+        assert result.contains('and more')
+        assert result.contains('def x = { it }')
+    }
+
+    @Test
+    void 
testReplaceTags_codeWithMultilineSingleQuoteTextBlockContainingBrace() {
+        def text = """\
+            {@code
+            def s = '''
+              multi-line with }
+              and more
+            '''
+            def x = { it }
+            }
+            """.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.count('<CODE>') == 1
+        assert result.count('</CODE>') == 1
+        assert result.contains('multi-line with }')
+        assert result.contains("'''")
+        assert result.contains('def x = { it }')
+    }
+
+    @Test
+    void testReplaceTags_codeWithEmptyTextBlockThenBraceInCode() {
+        def text = '{@code s = """"""; t = { x }}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>s = """"""; t = { x }</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_codeWithTextBlockContainingEscapedQuotesAndBrace() {
+        // \""" inside the block should not close it early; the real closer is 
later.
+        def text = '{@code s = """a \\"\\"\\" } b"""; done}'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result == '<CODE>s = """a \\"\\"\\" } b"""; done</CODE>'
+    }
+
+    @Test
+    void testReplaceTags_snippetWithMultilineTextBlockContainingBrace() {
+        def text = '''\
+            {@snippet lang="groovy" :
+            def s = """
+              }
+            """
+            if (ok) { work() }
+            }
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('<pre><code')
+        assert result.contains('}')
+        assert result.contains('if (ok) { work() }')
+        assert !result.contains('{@snippet')
+        assert result.contains('</code></pre>')
+    }
+
+    @Test
+    void testReplaceTags_codeInsideBlockTagWithTextBlockBrace() {
+        def text = '''\
+            Description.
+            @param x uses {@code s = """ } """} carefully
+            @return ok
+            '''.stripIndent()
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('<CODE>s = """ } """</CODE>')
+        assert result.contains('carefully')
+        assert result.contains('Parameters')
+        assert result.contains('Returns')
+    }
+
+    @Test
+    void testReplaceTags_codeWithUnterminatedTextBlockConsumesRest() {
+        // Unterminated """ treats the remainder as inside the block, so the
+        // would-be tag closer is not seen — the tag is left literal.
+        def text = '{@code s = """ still open }'
+
+        def result = classDoc.replaceTags(text)
+
+        assert result.contains('{@code')
+        assert result.contains('still open')
+    }
+
     @Test
     void testEncodeAngleBracketsInTagBody() {
         def text = 'text with <tag1> outside and {@code <tag2> inside} a @code 
tag'


Reply via email to