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 `}`, 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..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<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 ...}} (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 ->') + } + + @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'
