You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/04/21 22:40:38 UTC

(groovy) 01/05: groovydoc: groovyTestCase support for snippets

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 47148ef8926859ec9d6f76460a8fbd44b3bd5010
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Apr 22 07:04:39 2026 +1000

    groovydoc: groovyTestCase support for snippets
---
 .../groovy-groovydoc/src/spec/doc/groovydoc.adoc   | 33 ++++++++
 .../groovy/test/JavadocAssertionTestBuilder.groovy | 98 ++++++++++++++++++++--
 .../test/JavadocAssertionTestBuilderTest.groovy    | 59 +++++++++++++
 3 files changed, 182 insertions(+), 8 deletions(-)

diff --git a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
index 89d6bf8ada..0640248632 100644
--- a/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
+++ b/subprojects/groovy-groovydoc/src/spec/doc/groovydoc.adoc
@@ -479,6 +479,39 @@ The language is optional; when set, it becomes the Prism language class
 used for syntax highlighting. Any text from the opening brace to the
 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:
+
+[source,groovy]
+----
+/**
+ * {@snippet lang="groovy" :
+ * println '}'    // <-- stray literal } terminates the snippet body here
+ * }
+ */
+----
+
+Three workarounds when an example really needs an unbalanced literal
+brace:
+
+* Rewrite the example so all braces balance (often trivially possible,
+  e.g. `println '${"}"}'` or a string-concatenation alternative).
+* Move the sample into a file and use the external form
+  (`{@snippet file="Example.groovy"}`), which reads the file verbatim
+  without brace-balanced parsing.
+* Write the `}` as the HTML entity `&#125;`, which groovydoc counts as a
+  single non-brace character during body extraction but renders as `}`
+  in the output.
+
 ==== External file reference
 
 Place the source of the snippet in a `snippet-files/` subdirectory of
diff --git a/subprojects/groovy-test/src/main/groovy/groovy/test/JavadocAssertionTestBuilder.groovy b/subprojects/groovy-test/src/main/groovy/groovy/test/JavadocAssertionTestBuilder.groovy
index 3e9bbfbac8..6f6e3b6105 100644
--- a/subprojects/groovy-test/src/main/groovy/groovy/test/JavadocAssertionTestBuilder.groovy
+++ b/subprojects/groovy-test/src/main/groovy/groovy/test/JavadocAssertionTestBuilder.groovy
@@ -22,7 +22,7 @@ import java.util.regex.Pattern
 
 /**
  * <code>JavadocAssertionTestBuilder</code> dynamically creates test cases from Groovy assertions placed within
- * the doc comments of a source file. Three authoring forms are recognised:
+ * the doc comments of a source file. Four authoring forms are recognised:
  *
  * <ol>
  * <li>Traditional Javadoc with an HTML wrapper carrying <code>class="groovyTestCase"</code>:
@@ -38,30 +38,48 @@ import java.util.regex.Pattern
  * /// assert 2 + 2 == 4
  * /// ```
  * </pre></li>
+ * <li>JEP 413 <code>{@snippet}</code> tag with a <code>class</code> attribute containing <code>groovyTestCase</code>.
+ * The body is brace-balanced per JEP 413, so the containing assertion may use literal <code>&lt;</code> /
+ * <code>&gt;</code> without HTML escaping. Any <code>{</code> / <code>}</code> pairs inside the body must still
+ * balance (groovydoc's snippet parser does dumb brace counting with no string-awareness; the same rule applies here).
+ * <pre>/&#42;&#42; {&#64;snippet lang="groovy" class="groovyTestCase" :
+ *   assert "a&lt;b&gt;c".size() == 5
+ * } &#42;/</pre></li>
  * </ol>
  *
  * When extracting the code for an HTML-wrapper test, single-line snippets of code within a {{@code @code} ...}
  * tag have the javadoc {@code code} tag stripped, and HTML entities are converted back so that {@code &lt;} and
- * {@code &gt;} become {@code <} and {@code >}. Fenced-block extractions pass through without HTML decoding
- * because Markdown fenced blocks hold raw source already.
+ * {@code &gt;} become {@code <} and {@code >}. Fenced-block and {@code {@snippet}} extractions pass through
+ * without HTML decoding because those forms hold raw source already.
  */
 class JavadocAssertionTestBuilder {
     // TODO write tests for this classes functionality
     // Match both traditional `/&#42;&#42; ... &#42;/` Javadoc and contiguous `///` Markdown doc-comment runs.
     private static final Pattern javadocPattern =
         Pattern.compile( /(?ims)\/\*\*.*?\*\/|(?-s:(?:^[ \t]*\/\/\/[^\n]*\n?)+)/ )
-    // Two authoring forms for the assertion inside a doc comment:
+    // Three authoring forms for the assertion inside a doc comment:
     //   - HTML wrapper:   <pre class="groovyTestCase">...</pre>  (or <code>, <samp>, ...).
     //     Other classes may be present alongside groovyTestCase — e.g.
     //     <pre class="language-groovy groovyTestCase"> combines Prism highlighting
     //     with test extraction.
     //   - Fenced Markdown: ```groovy groovyTestCase ... ``` — only recognised within `///` runs
     //     (Javadoc `/** */` is not Markdown, so there's no ambiguity).
+    //   - JEP 413 {@snippet}: {@snippet lang="groovy" class="groovyTestCase" : body}
+    //     The body is brace-balanced per JEP 413, so regex can't capture it
+    //     directly. The opener regex below locates the tag; the body is then
+    //     extracted programmatically by walking braces in findSnippetTestCaseTags.
     private static final Pattern assertionPattern =
         Pattern.compile(
             /(?ims)<([a-z]+)\s+class\s*=\s*['"][^'"]*\bgroovyTestCase\b[^'"]*['"]\s*>.*?<\s*\/\s*\1>/
             + /|(?m:^[ \t]*\/\/\/[ \t]*```groovy[ \t]+groovyTestCase\b[^\n]*\n(?s:.*?)^[ \t]*\/\/\/[ \t]*```)/
         )
+    // {@snippet ... class="...groovyTestCase..." ... :   — opener up to the body-introducing colon.
+    // [^:{}]* keeps the match inside a single tag; attribute values don't
+    // contain braces or colons in practice.
+    private static final Pattern snippetOpenerPattern =
+        Pattern.compile(
+            /(?s)\{@snippet\b[^:{}]*class\s*=\s*['"][^'"]*\bgroovyTestCase\b[^'"]*['"][^:{}]*:/
+        )
 
     Class buildTest(String filename, String code) {
         Class test = null
@@ -85,11 +103,43 @@ class JavadocAssertionTestBuilder {
 
         code.eachMatch(javadocPattern) { javadoc ->
             assertions.addAll(javadoc.findAll(assertionPattern))
+            assertions.addAll(findSnippetTestCaseTags(javadoc))
         }
 
         return assertions
     }
 
+    // Scan {@snippet ... class="...groovyTestCase..." : body } tags and return
+    // each full tag string from '{@snippet' to the matching close brace.
+    // Body brace-balancing is done programmatically because regex can't count
+    // nested braces — this mirrors the parser in
+    // org.codehaus.groovy.tools.groovydoc.TagRenderer which does the same dumb
+    // counting (no string/comment awareness) so the semantics stay consistent
+    // between rendered output and test extraction.
+    private static List<String> findSnippetTestCaseTags(String javadoc) {
+        List<String> results = []
+        def m = snippetOpenerPattern.matcher(javadoc)
+        while (m.find()) {
+            int start = m.start()
+            int bodyStart = m.end()  // immediately after the `:` that opens the body
+            int depth = 1
+            int j = bodyStart
+            int n = javadoc.length()
+            while (j < n && depth > 0) {
+                char c = javadoc.charAt(j)
+                if (c == (char) '{') depth++
+                else if (c == (char) '}') {
+                    depth--
+                    if (depth == 0) break
+                }
+                j++
+            }
+            if (depth != 0) continue  // unterminated tag; skip
+            results << javadoc.substring(start, j + 1)
+        }
+        return results
+    }
+
     private String getTestName(String filename) {
         String filenameWithoutPath = new File(filename).name
         String testName = filenameWithoutPath.substring(0, filenameWithoutPath.lastIndexOf(".")) +
@@ -121,15 +171,47 @@ class JavadocAssertionTestBuilder {
     }
 
     private String getAssertion(String tag) {
-        // Dispatch on the leading non-whitespace character — the unified
-        // assertion pattern can match either an HTML wrapper (`<...>...</...>`)
-        // or a fenced code block (whose first non-blank line starts with
-        // `///` leading a backtick fence).
+        // Dispatch on the leading non-whitespace character.
         String trimmed = tag.trim()
         if (trimmed.startsWith('<')) return getHtmlWrapperAssertion(tag)
+        if (trimmed.startsWith('{@snippet')) return getSnippetAssertion(tag)
         return getFencedAssertion(tag)
     }
 
+    // Extract the body of a {@snippet ... class="...groovyTestCase..." : body }
+    // tag. The body is the text between the body-opener `:` and the matching
+    // closing `}` (tag was pre-sliced by findSnippetTestCaseTags). Line
+    // prefixes (`*` for /** */ comments, `///` for Markdown doc comments) are
+    // stripped. No HTML-entity decoding — snippet bodies are verbatim per JEP 413.
+    private String getSnippetAssertion(String tag) {
+        int colon = findSnippetBodyColon(tag)
+        int bodyEnd = tag.lastIndexOf('}')
+        if (colon < 0 || bodyEnd <= colon) return ''
+        String body = tag.substring(colon + 1, bodyEnd)
+        return body.replaceAll("(?m)^\\s*(?:\\*|\\/\\/\\/)\\s?", "")
+    }
+
+    // Find the `:` that introduces the body, skipping any `:` that appears
+    // inside a quoted attribute value. Returns -1 if not found.
+    private static int findSnippetBodyColon(String tag) {
+        int i = '{@snippet'.length()
+        int n = tag.length()
+        while (i < n) {
+            char c = tag.charAt(i)
+            if (c == (char) '"' || c == (char) "'") {
+                char q = c
+                i++
+                while (i < n && tag.charAt(i) != q) i++
+                if (i < n) i++  // consume closing quote
+            } else if (c == (char) ':') {
+                return i
+            } else {
+                i++
+            }
+        }
+        return -1
+    }
+
     private String getHtmlWrapperAssertion(String tag) {
         String tagInner = tag.substring(tag.indexOf(">")+1, tag.lastIndexOf("<"))
         // Strip `* ` (traditional Javadoc) OR `/// ` (JEP 467 Markdown) line prefixes.
diff --git a/subprojects/groovy-test/src/test/groovy/groovy/test/JavadocAssertionTestBuilderTest.groovy b/subprojects/groovy-test/src/test/groovy/groovy/test/JavadocAssertionTestBuilderTest.groovy
index dec514b8de..e64a02f7e9 100644
--- a/subprojects/groovy-test/src/test/groovy/groovy/test/JavadocAssertionTestBuilderTest.groovy
+++ b/subprojects/groovy-test/src/test/groovy/groovy/test/JavadocAssertionTestBuilderTest.groovy
@@ -64,6 +64,65 @@ class JavadocAssertionTestBuilderTest {
         assert test == null
     }
 
+    @Test
+    void testSnippetTagWithGroovyTestCaseClassIsRecognised() {
+        // {@snippet} body is verbatim per JEP 413, so no HTML-entity escaping.
+        Class test = builder.buildTest("SomeClass.java",
+                '''/**
+                   | * {@snippet lang="groovy" class="groovyTestCase" :
+                   | * assert "a<b>c".size() == 5
+                   | * }
+                   | */
+                   |public class SomeClass { }'''.stripMargin())
+        assert test != null
+        test.newInstance().testAssertionFromSomeClassLine2()
+    }
+
+    @Test
+    void testSnippetTagAssertionsAreExecuted() {
+        Class test = builder.buildTest("SomeClass.java",
+                '''/**
+                   | * {@snippet lang="groovy" class="groovyTestCase" :
+                   | * assert false
+                   | * }
+                   | */
+                   |public class SomeClass { }'''.stripMargin())
+        shouldFail(AssertionError) {
+            test.newInstance().testAssertionFromSomeClassLine2()
+        }
+    }
+
+    @Test
+    void testSnippetTagWithBalancedBracesInBody() {
+        // Brace-balanced body — closures and GStrings contribute {/} pairs that
+        // must balance for the tag parser to find the correct closing brace.
+        Class test = builder.buildTest("SomeClass.java",
+                '''/**
+                   | * {@snippet lang="groovy" class="groovyTestCase" :
+                   | * def items = [1, 2, 3]
+                   | * def sum = items.inject(0) { acc, x -> acc + x }
+                   | * assert sum == 6
+                   | * assert "${sum}" == "6"
+                   | * }
+                   | */
+                   |public class SomeClass { }'''.stripMargin())
+        assert test != null
+        test.newInstance().testAssertionFromSomeClassLine2()
+    }
+
+    @Test
+    void testSnippetTagWithoutGroovyTestCaseClassIsIgnored() {
+        Class test = builder.buildTest("SomeClass.java",
+                '''/**
+                   | * {@snippet lang="groovy" :
+                   | * assert false
+                   | * }
+                   | */
+                   |public class SomeClass { }'''.stripMargin())
+        // No groovyTestCase marker — snippet is a plain example, not a test.
+        assert test == null
+    }
+
     @Test
     void testLineNumbering() {
         Class test = builder.buildTest("SomeClass.java", '''