This is an automated email from the ASF dual-hosted git repository.
daniellansun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new a184bddbbe GROOVY-12095: Code in groovydoc does not display correctly
(#2716)
a184bddbbe is described below
commit a184bddbbe845d239fa4d286773b3869e5d5758d
Author: Daniel Sun <[email protected]>
AuthorDate: Sat Jul 18 19:40:00 2026 +0800
GROOVY-12095: Code in groovydoc does not display correctly (#2716)
---
.../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 `}`, 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 `}`, 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<B>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 ->')
+ }
+
+ @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 -> 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 -> 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 ->')
+ }
+
+ /**
+ * 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'