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 `}`, 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'
