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 7e34f011f0b7a4bc9da3b95b2c8aeacf92ac6718
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        | 108 +++++++--
 .../groovy-groovydoc/src/spec/doc/groovydoc.adoc   |  45 ++--
 .../groovy/tools/groovydoc/GroovyDocToolTest.java  |  15 +-
 .../tools/groovydoc/PreLanguageRewriterTest.groovy |  27 +++
 .../groovydoc/SimpleGroovyClassDocTests.groovy     | 250 +++++++++++++++++++++
 7 files changed, 433 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..9e2b055dca 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
@@ -18,6 +18,8 @@
  */
 package org.codehaus.groovy.tools.groovydoc;
 
+import groovy.util.regex.BalancedGroup;
+
 import org.codehaus.groovy.groovydoc.GroovyClassDoc;
 import org.codehaus.groovy.groovydoc.GroovyFieldDoc;
 import org.codehaus.groovy.groovydoc.GroovyMemberDoc;
@@ -58,6 +60,17 @@ 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 via
+ * {@link BalancedGroup} (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. Double- and single-quoted
+ * string/character literals (with backslash escapes) and block comments
+ * are skipped while matching, 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}
@@ -90,6 +103,31 @@ import java.util.regex.PatternSyntaxException;
  */
 final class TagRenderer {
 
+    /**
+     * Spans to skip while brace-matching via {@link BalancedGroup}:
+     * <ul>
+     *   <li>double-quoted string literals with backslash escapes
+     *       (covers {@code System.out.println(" } ")} and GString
+     *       {@code "value=${x}"})</li>
+     *   <li>single-quoted string/character literals
+     *       (covers {@code char c = '}'})</li>
+     *   <li>block comments (so a {@code '}'} inside {@code /* ... *}{@code /}
+     *       does not end the tag)</li>
+     * </ul>
+     * Line comments ({@code // ...}) are intentionally <em>not</em> ignored: 
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.
+     */
+    private static final String BRACE_LITERAL_IGNORE =
+            
"\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|/\\*(?:[^*]|\\*(?!/))*\\*/";
+
+    /**
+     * Shared match options for inline-tag / snippet brace balancing.
+     * Tokenizer patterns are LRU-cached inside {@link BalancedGroup}.
+     */
+    private static final BalancedGroup.MatchOptions BRACE_MATCH_OPTIONS =
+            
BalancedGroup.MatchOptions.defaults().withIgnoreRegex(BRACE_LITERAL_IGNORE);
+
     /** Block-tag names that get merged under a single display heading. */
     static final Map<String, String> COLLATED_TAGS = new LinkedHashMap<>();
     static {
@@ -304,8 +342,10 @@ 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 (via BalancedGroup) so nested
+        // `{`/`}` inside {@code ...} / {@literal ...} (and other inline tags)
+        // do not terminate the tag early; string/char literals are ignored.
+        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 +434,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 (BalancedGroup + literal ignore) — 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 +1480,48 @@ 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>
+     * Delegates to {@link BalancedGroup#find(CharSequence, String, String, 
BalancedGroup.MatchOptions)}
+     * with {@link #BRACE_MATCH_OPTIONS}: nested braces in code bodies are
+     * balanced correctly, while braces that appear inside double- or
+     * single-quoted literals (e.g. {@code System.out.println(" } ")}) are
+     * ignored and do not terminate the tag.
+     * </p>
+     * <p>
+     * Only the suffix starting at {@code openPos} is scanned, so preceding
+     * comment text is not re-tokenized for every inline tag. Tokenizer
+     * patterns are LRU-cached inside {@link BalancedGroup}.
+     * </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;
+        }
+        // Scan only from the known open brace. Orphan-rescue may promote
+        // completed *inner* groups when the outer opener is never closed, so
+        // require a root that starts exactly at the open brace (relative
+        // offset 0 in the suffix).
+        List<BalancedGroup> roots = BalancedGroup.find(
+                text.substring(openPos), "\\{", "\\}", BRACE_MATCH_OPTIONS);
+        if (roots.isEmpty() || roots.get(0).getFullStart() != 0) {
+            return -1;
+        }
+        // fullEnd is exclusive; the matching '}' sits at fullEnd - 1.
+        return openPos + roots.get(0).getFullEnd() - 1;
+    }
 }
diff --git a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc 
b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
index 9fab57b12b..d534316039 100644
--- a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
+++ b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
@@ -166,10 +166,15 @@ 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 via `BalancedGroup` (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(" } ")`) 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 +518,38 @@ 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 uses `groovy.util.regex.BalancedGroup` with an ignore rule
+for double- and single-quoted string/character literals (backslash
+escapes included) and `/* ... */` block comments. Nested braces in real
+code — closures, `catalog.each { book -> ... }`, GString
+`"value=${x}"`, and even unbalanced braces *inside* quotes such as
+`System.out.println(" } ")` — are 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..e98bbb5b73 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,256 @@ 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 ...}} (BalancedGroup ignoreRegex).
+     */
+    @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>'
+    }
+
     @Test
     void testEncodeAngleBracketsInTagBody() {
         def text = 'text with <tag1> outside and {@code <tag2> inside} a @code 
tag'

Reply via email to