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 `}`, 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><</code> /
+ * <code>></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>/** {@snippet lang="groovy" class="groovyTestCase" :
+ * assert "a<b>c".size() == 5
+ * } */</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 <} and
- * {@code >} become {@code <} and {@code >}. Fenced-block extractions pass through without HTML decoding
- * because Markdown fenced blocks hold raw source already.
+ * {@code >} 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 `/** ... */` 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", '''