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 b9a3822a2b GROOVY-12133: Implement findBalancedGroups utility to 
support Regex Balanced Groups (#2669)
b9a3822a2b is described below

commit b9a3822a2b24ec8a87101cd7b3c04b68683db504
Author: Daniel Sun <[email protected]>
AuthorDate: Sat Jul 18 00:34:41 2026 +0800

    GROOVY-12133: Implement findBalancedGroups utility to support Regex 
Balanced Groups (#2669)
    
    * GROOVY-12133: Implement findBalancedGroups utility to support Regex 
Balanced Groups
    
    * Minor tweak
---
 src/main/java/groovy/util/regex/BalancedGroup.java | 503 ++++++++++++++++++
 .../groovy/runtime/StringGroovyMethods.java        |  91 ++++
 src/test/groovy/bugs/Groovy12133.groovy            | 572 +++++++++++++++++++++
 .../util/regex/BalancedGroupConstructorTest.groovy | 100 ++++
 4 files changed, 1266 insertions(+)

diff --git a/src/main/java/groovy/util/regex/BalancedGroup.java 
b/src/main/java/groovy/util/regex/BalancedGroup.java
new file mode 100644
index 0000000000..3c73705203
--- /dev/null
+++ b/src/main/java/groovy/util/regex/BalancedGroup.java
@@ -0,0 +1,503 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.util.regex;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A node in the tree of balanced groups extracted by {@link #find},
+ * also exposed on {@link CharSequence} via
+ * {@link 
org.codehaus.groovy.runtime.StringGroovyMethods#findBalancedGroups(CharSequence,
 String, String)}.
+ * <p>
+ * Java's {@link Pattern} has no .NET-style balancing groups
+ * ({@code (?<name1-name2>…)}). This type is both the structured result and the
+ * entry point for Groovy's stack-based equivalent: each node is one 
successfully
+ * closed span, with immediately nested spans as children (richer than .NET's 
flat
+ * {@code CaptureCollection} on a single group).
+ * </p>
+ * <p>
+ * Offsets mirror .NET {@code Capture.Index} / {@code Length}: {@link 
#getStart()}
+ * and {@link #getEnd()} describe the range of {@link #getMatchedString()} in 
the
+ * original input (half-open {@code [start, end)}). {@link #getFullStart()} /
+ * {@link #getFullEnd()} always cover the complete pair including delimiters,
+ * even when {@link MatchOptions#includeEdges()} is {@code false} (comparable 
to
+ * knowing both the balancing capture and the open/close match positions).
+ * </p>
+ * <p>
+ * Parent links and nesting depth are wired when a node is attached as a child
+ * of another node during construction; root nodes have a {@code null} parent
+ * and depth {@code 0}. Prefer {@link #find} (or the GDK methods on
+ * {@link CharSequence}) as the public entry point — constructors are
+ * package-private because they perform one-shot parent wiring.
+ * </p>
+ *
+ * @since 6.0.0
+ */
+public final class BalancedGroup {
+
+    /**
+     * Bounded LRU cache for compiled tokenizer patterns. Hot call sites often
+     * reuse the same open/close/ignore triple; recompiling those on every call
+     * dominates cost for short inputs.
+     */
+    private static final int TOKENIZER_CACHE_LIMIT = 64;
+
+    private static final Map<String, Pattern> TOKENIZER_CACHE =
+        new LinkedHashMap<>(TOKENIZER_CACHE_LIMIT, 0.75f, true) {
+            @Override
+            protected boolean removeEldestEntry(Map.Entry<String, Pattern> 
eldest) {
+                return size() > TOKENIZER_CACHE_LIMIT;
+            }
+        };
+
+    private final String matchedString;
+    private final int start;
+    private final int end;
+    private final int fullStart;
+    private final int fullEnd;
+    private final List<BalancedGroup> children;
+    private BalancedGroup parent;
+    /** Nesting depth; set once when this node is attached as a child (O(1) 
{@link #getDepth()}). */
+    private int depth;
+
+    /**
+     * Package-private constructor: builds a node with offsets relative to
+     * {@code matchedString} ({@code start = 0}, {@code end = 
matchedString.length()},
+     * and the same for the full span). Prefer {@link #find} for public use.
+     * Wires each child's parent link (one-shot: a child may not already have 
a parent).
+     *
+     * @param matchedString the text captured for this group (never {@code 
null})
+     * @param children      immediate nested groups, or {@code null}/empty for 
a leaf
+     * @throws NullPointerException     if {@code matchedString} is {@code 
null}, or
+     *                                  {@code children} contains {@code null}
+     * @throws IllegalArgumentException if any child already has a parent
+     */
+    BalancedGroup(String matchedString, List<BalancedGroup> children) {
+        this(matchedString, 0, lengthOf(matchedString), 0, 
lengthOf(matchedString), children);
+    }
+
+    /**
+     * Package-private constructor: builds a node with absolute source offsets 
and children.
+     * Prefer {@link #find} for public use. Wires each child's parent link and 
depth
+     * (one-shot: a child may not already have a parent).
+     *
+     * @param matchedString the text captured for this group (never {@code 
null});
+     *                      may include or exclude boundary delimiters 
depending on
+     *                      {@link MatchOptions#includeEdges()}
+     * @param start         start index of {@code matchedString} in the source 
(inclusive)
+     * @param end           end index of {@code matchedString} in the source 
(exclusive)
+     * @param fullStart     start index of the full pair including open 
delimiter
+     * @param fullEnd       end index of the full pair including close 
delimiter (exclusive)
+     * @param children      immediate nested groups, or {@code null}/empty for 
a leaf
+     * @throws NullPointerException     if {@code matchedString} is {@code 
null}, or
+     *                                  {@code children} contains {@code null}
+     * @throws IllegalArgumentException if ranges are invalid or a child 
already has a parent
+     */
+    BalancedGroup(String matchedString, int start, int end, int fullStart, int 
fullEnd,
+                  List<BalancedGroup> children) {
+        this.matchedString = Objects.requireNonNull(matchedString, 
"matchedString");
+        if (start < 0 || end < start) {
+            throw new IllegalArgumentException("invalid match range: [" + 
start + ", " + end + ")");
+        }
+        if (fullStart < 0 || fullEnd < fullStart) {
+            throw new IllegalArgumentException("invalid full range: [" + 
fullStart + ", " + fullEnd + ")");
+        }
+        if (end - start != matchedString.length()) {
+            throw new IllegalArgumentException(
+                "matchedString length " + matchedString.length() + " != range 
length " + (end - start));
+        }
+        this.start = start;
+        this.end = end;
+        this.fullStart = fullStart;
+        this.fullEnd = fullEnd;
+        if (children == null || children.isEmpty()) {
+            this.children = List.of();
+        } else {
+            this.children = List.copyOf(children);
+            for (BalancedGroup child : this.children) {
+                if (child.parent != null) {
+                    throw new IllegalArgumentException("child BalancedGroup 
already has a parent");
+                }
+                child.parent = this;
+                // Bottom-up assembly: this node may later be attached under a 
grandparent,
+                // so depth is assigned (and cascaded) here and again when we 
are attached.
+                child.assignDepth(this.depth + 1);
+            }
+        }
+    }
+
+    /**
+     * Sets this node's depth and cascades to descendants. Invoked only during 
the
+     * one-shot parent-wiring phase of construction (tree is not yet 
published).
+     */
+    private void assignDepth(int newDepth) {
+        this.depth = newDepth;
+        for (BalancedGroup child : children) {
+            child.assignDepth(newDepth + 1);
+        }
+    }
+
+    private static int lengthOf(String s) {
+        return s == null ? 0 : s.length();
+    }
+
+    /**
+     * Finds balanced groups using {@link MatchOptions#defaults()}.
+     *
+     * @param text       text to scan
+     * @param openRegex  regex for an opening delimiter (must not be empty)
+     * @param closeRegex regex for a closing delimiter (must not be empty)
+     * @return unmodifiable list of outermost groups (empty if none)
+     * @see #find(CharSequence, String, String, MatchOptions)
+     * @since 6.0.0
+     */
+    public static List<BalancedGroup> find(CharSequence text, String 
openRegex, String closeRegex) {
+        return find(text, openRegex, closeRegex, MatchOptions.defaults());
+    }
+
+    /**
+     * Finds balanced (nested) groups in {@code text} and returns them as a 
forest of
+     * {@link BalancedGroup} nodes.
+     * <p>
+     * <b>Relation to .NET balancing groups:</b> .NET keeps a capture stack per
+     * named group, pushes with {@code (?&lt;Open&gt;…)}, pops with
+     * {@code (?&lt;-Open&gt;…)} or {@code (?&lt;Between-Open&gt;…)}, and can 
require a
+     * fully empty stack via {@code (?(Open)(?!))}. This method uses the same
+     * push/pop idea on an explicit stack, but returns a hierarchical tree
+     * (children + parent) rather than a flat {@code CaptureCollection}, and
+     * always extracts every completed span in one left-to-right scan.
+     * </p>
+     * <p>
+     * <b>Algorithm:</b> matches of a combined tokenizer
+     * ({@code IGNORE}? / {@code OPEN} / {@code CLOSE}) are consumed in order.
+     * Nesting is resolved by the stack, not by recursive regex, so balancing
+     * itself does not introduce ReDoS; cost is proportional to the number of
+     * tokenizer hits times the cost of the user-supplied patterns. Compiled
+     * tokenizers are cached (bounded LRU).
+     * </p>
+     * <p>
+     * <b>Fault tolerance</b> (differs from a strict .NET
+     * {@code (?(Open)(?!))} pattern): unmatched closers are ignored; unclosed
+     * openers are dropped at end-of-input, but groups already completed inside
+     * them are promoted outward ("orphan rescue").
+     * </p>
+     * <p>
+     * Named capturing groups {@code OPEN}, {@code CLOSE}, and {@code IGNORE} 
are
+     * reserved for the tokenizer; do not use those names inside
+     * {@code openRegex}, {@code closeRegex}, or {@link 
MatchOptions#ignoreRegex()}.
+     * </p>
+     *
+     * @param text       text to scan
+     * @param openRegex  regex for an opening delimiter (must not be empty)
+     * @param closeRegex regex for a closing delimiter (must not be empty)
+     * @param options    match options; {@code null} means {@link 
MatchOptions#defaults()}
+     * @return unmodifiable list of outermost groups (empty if none)
+     * @throws NullPointerException     if {@code text}, {@code openRegex}, or 
{@code closeRegex} is {@code null}
+     * @throws IllegalArgumentException if {@code openRegex} or {@code 
closeRegex} is empty
+     * @throws java.util.regex.PatternSyntaxException if a supplied pattern is 
not a valid Java regex
+     * @since 6.0.0
+     */
+    public static List<BalancedGroup> find(CharSequence text, String 
openRegex, String closeRegex,
+                                           MatchOptions options) {
+        Objects.requireNonNull(text, "text must not be null");
+        Objects.requireNonNull(openRegex, "openRegex must not be null");
+        Objects.requireNonNull(closeRegex, "closeRegex must not be null");
+        if (openRegex.isEmpty()) {
+            throw new IllegalArgumentException("openRegex must not be empty");
+        }
+        if (closeRegex.isEmpty()) {
+            throw new IllegalArgumentException("closeRegex must not be empty");
+        }
+
+        MatchOptions actualOptions = options != null ? options : 
MatchOptions.defaults();
+        String ignoreRegex = actualOptions.ignoreRegex();
+        boolean hasIgnoreRule = ignoreRegex != null && !ignoreRegex.isBlank();
+        boolean includeEdges = actualOptions.includeEdges();
+
+        // Single String source: Matcher indices and substring share the same 
coordinate space;
+        // avoids repeated CharSequence.subSequence materialization for 
non-String inputs.
+        final String source = text instanceof String ? (String) text : 
text.toString();
+
+        Matcher matcher = tokenizer(openRegex, closeRegex, hasIgnoreRule ? 
ignoreRegex : null).matcher(source);
+
+        // Virtual super-root so top-level groups need no empty-stack special 
case.
+        Deque<PendingGroup> stack = new ArrayDeque<>();
+        stack.push(new PendingGroup(-1, -1));
+
+        while (matcher.find()) {
+            if (hasIgnoreRule && matcher.start("IGNORE") >= 0) {
+                continue;
+            }
+
+            if (matcher.start("OPEN") >= 0) {
+                stack.push(new PendingGroup(matcher.start(), matcher.end()));
+            } else if (matcher.start("CLOSE") >= 0 && stack.size() > 1) {
+                PendingGroup openGroup = stack.pop();
+                int openStart = openGroup.openStart;
+                int openEnd = openGroup.openEnd;
+                int closeStart = matcher.start();
+                int closeEnd = matcher.end();
+
+                int matchStart = includeEdges ? openStart : openEnd;
+                int matchEnd = includeEdges ? closeEnd : closeStart;
+                // Pathological open/close patterns can invert the interior 
range.
+                if (matchStart > matchEnd) {
+                    matchStart = matchEnd;
+                }
+                String matchStr = source.substring(matchStart, matchEnd);
+
+                // Constructor wires child → parent links.
+                BalancedGroup completedNode = new BalancedGroup(
+                    matchStr, matchStart, matchEnd, openStart, closeEnd, 
openGroup.children());
+                stack.peek().addChild(completedNode);
+            }
+        }
+
+        // Promote completed children out of any still-open (dangling) frames.
+        while (stack.size() > 1) {
+            PendingGroup dangling = stack.pop();
+            stack.peek().addAllChildren(dangling);
+        }
+
+        return stack.peek().snapshotChildren();
+    }
+
+    /**
+     * Builds or reuses the tokenizer pattern. Alternation order is 
significant:
+     * IGNORE wins over OPEN, OPEN over CLOSE (same idea as putting more 
specific
+     * alternatives first in a hand-written .NET balancing pattern).
+     */
+    private static Pattern tokenizer(String openRegex, String closeRegex, 
String ignoreRegex) {
+        String key;
+        String pattern;
+        if (ignoreRegex != null) {
+            key = ignoreRegex + "\0" + openRegex + "\0" + closeRegex;
+            pattern = "(?<IGNORE>" + ignoreRegex + ")|(?<OPEN>" + openRegex + 
")|(?<CLOSE>" + closeRegex + ")";
+        } else {
+            key = openRegex + "\0" + closeRegex;
+            pattern = "(?<OPEN>" + openRegex + ")|(?<CLOSE>" + closeRegex + 
")";
+        }
+        synchronized (TOKENIZER_CACHE) {
+            Pattern cached = TOKENIZER_CACHE.get(key);
+            if (cached != null) {
+                return cached;
+            }
+            Pattern compiled = Pattern.compile(pattern);
+            TOKENIZER_CACHE.put(key, compiled);
+            return compiled;
+        }
+    }
+
+    /**
+     * Returns the text captured for this group.
+     *
+     * @return the matched text (never {@code null}); may include or exclude 
boundary
+     *         delimiters depending on {@link MatchOptions#includeEdges()}
+     */
+    public String getMatchedString() {
+        return matchedString;
+    }
+
+    /**
+     * Start index of {@link #getMatchedString()} in the original input 
(inclusive).
+     * Comparable to .NET {@code Capture.Index}.
+     *
+     * @return the start offset
+     */
+    public int getStart() {
+        return start;
+    }
+
+    /**
+     * End index of {@link #getMatchedString()} in the original input 
(exclusive).
+     * Length is {@code getEnd() - getStart()}, like .NET {@code 
Capture.Length}.
+     *
+     * @return the end offset
+     */
+    public int getEnd() {
+        return end;
+    }
+
+    /**
+     * Length of {@link #getMatchedString()} ({@code end - start}).
+     *
+     * @return the length
+     */
+    public int getLength() {
+        return end - start;
+    }
+
+    /**
+     * Start index of the full balanced pair, always including the opening 
delimiter.
+     *
+     * @return the full-span start offset
+     */
+    public int getFullStart() {
+        return fullStart;
+    }
+
+    /**
+     * End index of the full balanced pair, always including the closing 
delimiter (exclusive).
+     *
+     * @return the full-span end offset
+     */
+    public int getFullEnd() {
+        return fullEnd;
+    }
+
+    /**
+     * Returns the immediately nested balanced groups.
+     *
+     * @return an unmodifiable list of children; empty (never {@code null}) 
for a leaf
+     */
+    public List<BalancedGroup> getChildren() {
+        return children;
+    }
+
+    /**
+     * Returns the enclosing group, if any.
+     *
+     * @return the parent node, or {@code null} if this is a root
+     */
+    public BalancedGroup getParent() {
+        return parent;
+    }
+
+    /**
+     * Nesting depth of this node (0 for a root).
+     * Computed once when the parent link is wired; O(1).
+     *
+     * @return the number of ancestors
+     */
+    public int getDepth() {
+        return depth;
+    }
+
+    /**
+     * Returns the matched text of this group.
+     *
+     * @return the same value as {@link #getMatchedString()}
+     */
+    @Override
+    public String toString() {
+        return matchedString;
+    }
+
+    /**
+     * Frame for an as-yet unmatched opening delimiter during the scan.
+     * Child lists are allocated lazily: most frames are leaves.
+     */
+    private static final class PendingGroup {
+        final int openStart;
+        final int openEnd;
+        private ArrayList<BalancedGroup> completedChildren;
+
+        PendingGroup(int openStart, int openEnd) {
+            this.openStart = openStart;
+            this.openEnd = openEnd;
+        }
+
+        void addChild(BalancedGroup child) {
+            if (completedChildren == null) {
+                completedChildren = new ArrayList<>(2);
+            }
+            completedChildren.add(child);
+        }
+
+        void addAllChildren(PendingGroup other) {
+            if (other.completedChildren == null || 
other.completedChildren.isEmpty()) {
+                return;
+            }
+            if (completedChildren == null) {
+                completedChildren = other.completedChildren;
+                other.completedChildren = null;
+            } else {
+                completedChildren.addAll(other.completedChildren);
+            }
+        }
+
+        List<BalancedGroup> children() {
+            return completedChildren == null ? List.of() : completedChildren;
+        }
+
+        List<BalancedGroup> snapshotChildren() {
+            if (completedChildren == null || completedChildren.isEmpty()) {
+                return List.of();
+            }
+            return List.copyOf(completedChildren);
+        }
+    }
+
+    /**
+     * Options controlling balanced-group matching.
+     * <p>
+     * Instances are immutable; use the {@code with*} methods to derive 
variants.
+     * </p>
+     *
+     * @param ignoreRegex  regex for spans to skip while scanning (e.g. string 
literals
+     *                     or escape sequences); {@code null} or blank means 
ignore nothing.
+     *                     This is the practical counterpart to writing 
“neither open nor
+     *                     close” character classes in a hand-built .NET 
balancing pattern.
+     * @param includeEdges {@code true} to keep opening/closing delimiters in
+     *                     {@link BalancedGroup#getMatchedString()}; {@code 
false} to strip
+     *                     them (like .NET's balancing capture of the interval 
between
+     *                     the subtracted open capture and the close match)
+     *
+     * @since 6.0.0
+     */
+    public record MatchOptions(String ignoreRegex, boolean includeEdges) {
+
+        /**
+         * Default options: nothing ignored, delimiters included.
+         *
+         * @return the default options
+         */
+        public static MatchOptions defaults() {
+            return new MatchOptions(null, true);
+        }
+
+        /**
+         * Returns a copy with the given ignore regex.
+         *
+         * @param ignoreRegex regex of content to skip, or {@code null}/blank 
for none
+         * @return a new options instance
+         */
+        public MatchOptions withIgnoreRegex(String ignoreRegex) {
+            return new MatchOptions(ignoreRegex, this.includeEdges);
+        }
+
+        /**
+         * Returns a copy with the given edge-inclusion policy.
+         *
+         * @param includeEdges whether matched text keeps opening/closing 
delimiters
+         * @return a new options instance
+         */
+        public MatchOptions withIncludeEdges(boolean includeEdges) {
+            return new MatchOptions(this.ignoreRegex, includeEdges);
+        }
+    }
+}
diff --git a/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java 
b/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
index 306f094a0f..d6c4871103 100644
--- a/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
@@ -26,6 +26,7 @@ import groovy.lang.Range;
 import groovy.transform.stc.ClosureParams;
 import groovy.transform.stc.FromString;
 import groovy.transform.stc.PickFirstResolver;
+import groovy.util.regex.BalancedGroup;
 import org.apache.groovy.io.StringBuilderWriter;
 import org.apache.groovy.lang.annotation.Incubating;
 import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
@@ -4441,4 +4442,94 @@ public class StringGroovyMethods extends 
DefaultGroovyMethodsSupport {
 
         return 
self.toString().toLowerCase(Locale.ROOT).contains(searchString.toString().toLowerCase(Locale.ROOT));
     }
+
+    /**
+     * Finds balanced (nested) groups in this CharSequence using default 
options
+     * (no ignore pattern; opening and closing delimiters are kept in the 
match text).
+     * <p>
+     * This is Groovy's structured alternative to .NET regex balancing groups
+     * ({@code (?&lt;Open&gt;…)} / {@code (?&lt;Close-Open&gt;…)} / {@code 
(?(Open)(?!))}).
+     * Delegates to {@link BalancedGroup#find(CharSequence, String, String)}.
+     * Java {@link java.util.regex.Pattern} cannot express capture-stack 
push/pop, so
+     * nesting is resolved with an explicit stack and the result is a tree of
+     * {@link BalancedGroup} nodes (with {@link 
BalancedGroup#getStart()}/{@link BalancedGroup#getEnd()}
+     * offsets comparable to .NET {@code Capture.Index}/{@code Length}).
+     * </p>
+     * <pre class="groovyTestCase">
+     * def text = 'Root: (A + (B * C) + (D(E)))'
+     * def roots = text.findBalancedGroups(/\(/, /\)/)
+     * assert roots.size() == 1
+     * assert roots[0].matchedString == '(A + (B * C) + (D(E)))'
+     * assert roots[0].children*.matchedString == ['(B * C)', '(D(E))']
+     * assert roots[0].children[1].children[0].matchedString == '(E)'
+     * assert text.substring(roots[0].start, roots[0].end) == 
roots[0].matchedString
+     * assert roots[0].children[0].depth == 1
+     * </pre>
+     * <p>
+     * Unmatched closers are ignored; unclosed openers are dropped but 
completed
+     * groups nested inside them are still returned (more tolerant than a 
strict
+     * .NET {@code (?(Open)(?!))} whole-string match).
+     * </p>
+     *
+     * @param self       a CharSequence
+     * @param openRegex  regular expression for the opening delimiter (must 
not be empty)
+     * @param closeRegex regular expression for the closing delimiter (must 
not be empty)
+     * @return an unmodifiable list of outermost {@link BalancedGroup} nodes 
(empty if none)
+     * @throws NullPointerException     if {@code self}, {@code openRegex}, or 
{@code closeRegex} is {@code null}
+     * @throws IllegalArgumentException if {@code openRegex} or {@code 
closeRegex} is empty
+     * @throws java.util.regex.PatternSyntaxException if a pattern is not a 
valid Java regex
+     *
+     * @see BalancedGroup#find(CharSequence, String, String)
+     * @see #findBalancedGroups(CharSequence, String, String, 
BalancedGroup.MatchOptions)
+     * @since 6.0.0
+     */
+    public static List<BalancedGroup> findBalancedGroups(final CharSequence 
self, final String openRegex, final String closeRegex) {
+        return BalancedGroup.find(self, openRegex, closeRegex);
+    }
+
+    /**
+     * Finds balanced (nested) groups in this CharSequence with the given 
options.
+     * Delegates to {@link BalancedGroup#find(CharSequence, String, String, 
BalancedGroup.MatchOptions)}.
+     * <pre class="groovyTestCase">
+     * import groovy.util.regex.BalancedGroup
+     * def opts = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+     * def g = 'func(arg1, arg2)'.findBalancedGroups(/\(/, /\)/, opts)[0]
+     * assert g.matchedString == 'arg1, arg2'
+     * assert g.start == 5 &amp;&amp; g.end == 15
+     * assert g.fullStart == 4 &amp;&amp; g.fullEnd == 16
+     *
+     * def code = 'if (true) { String s = "}"; }'
+     * def ignoreStrings = BalancedGroup.MatchOptions.defaults()
+     *     .withIgnoreRegex(/"(?:\\.|[^"\\])*"/)
+     * assert code.findBalancedGroups(/\{/, /\}/, 
ignoreStrings)[0].matchedString == '{ String s = "}"; }'
+     * </pre>
+     * <p>
+     * {@link BalancedGroup.MatchOptions#includeEdges()}{@code false} keeps 
only the
+     * interior text (like .NET's balancing capture of the interval between the
+     * open capture and the close). {@link 
BalancedGroup.MatchOptions#ignoreRegex()}
+     * skips spans such as string literals—the usual substitute for a 
hand-built
+     * “neither delimiter” class in a .NET balancing pattern.
+     * </p>
+     * <p>
+     * If {@code options} is {@code null}, {@link 
BalancedGroup.MatchOptions#defaults()} is used.
+     * Named groups {@code OPEN}, {@code CLOSE}, and {@code IGNORE} are 
reserved by the scanner.
+     * </p>
+     *
+     * @param self       a CharSequence
+     * @param openRegex  regular expression for the opening delimiter (must 
not be empty)
+     * @param closeRegex regular expression for the closing delimiter (must 
not be empty)
+     * @param options    match options, or {@code null} for defaults
+     * @return an unmodifiable list of outermost {@link BalancedGroup} nodes 
(empty if none)
+     * @throws NullPointerException     if {@code self}, {@code openRegex}, or 
{@code closeRegex} is {@code null}
+     * @throws IllegalArgumentException if {@code openRegex} or {@code 
closeRegex} is empty
+     * @throws java.util.regex.PatternSyntaxException if a pattern is not a 
valid Java regex
+     *
+     * @see BalancedGroup#find(CharSequence, String, String, 
BalancedGroup.MatchOptions)
+     * @see #findBalancedGroups(CharSequence, String, String)
+     * @since 6.0.0
+     */
+    public static List<BalancedGroup> findBalancedGroups(final CharSequence 
self, final String openRegex, final String closeRegex,
+                                                   final 
BalancedGroup.MatchOptions options) {
+        return BalancedGroup.find(self, openRegex, closeRegex, options);
+    }
 }
diff --git a/src/test/groovy/bugs/Groovy12133.groovy 
b/src/test/groovy/bugs/Groovy12133.groovy
new file mode 100644
index 0000000000..d1d2dddf53
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12133.groovy
@@ -0,0 +1,572 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package bugs
+
+import groovy.util.regex.BalancedGroup
+import org.codehaus.groovy.runtime.StringGroovyMethods
+import org.junit.jupiter.api.Test
+
+import java.util.regex.PatternSyntaxException
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertThrows
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * GROOVY-12133: findBalancedGroups — structured alternative to .NET balancing 
groups.
+ */
+final class Groovy12133 {
+
+    @Test
+    void testNullArgumentsThrowNpe() {
+        assertThrows(NullPointerException, () -> 
StringGroovyMethods.findBalancedGroups(null, '\\(', '\\)'))
+        assertThrows(NullPointerException, () -> 
StringGroovyMethods.findBalancedGroups('test', null, '\\)'))
+        assertThrows(NullPointerException, () -> 
StringGroovyMethods.findBalancedGroups('test', '\\(', null))
+    }
+
+    @Test
+    void testEmptyOpenOrCloseRegexRejected() {
+        assertThrows(IllegalArgumentException, () -> 
'x'.findBalancedGroups('', '\\)'))
+        assertThrows(IllegalArgumentException, () -> 
'x'.findBalancedGroups('\\(', ''))
+    }
+
+    @Test
+    void testNoMatchReturnsEmptyList() {
+        assertTrue('plain text'.findBalancedGroups('\\(', '\\)').isEmpty())
+    }
+
+    @Test
+    void testPerfectlyNestedBuildsTree() {
+        String input = 'Root: (A + (B * C) + (D(E)))'
+        List<BalancedGroup> roots = input.findBalancedGroups('\\(', '\\)')
+
+        assertEquals(1, roots.size())
+
+        BalancedGroup root = roots[0]
+        assertEquals('(A + (B * C) + (D(E)))', root.matchedString)
+        assertNull(root.parent)
+        assertEquals(0, root.depth)
+        assertEquals(2, root.children.size())
+
+        // Offsets comparable to .NET Capture.Index / Length
+        assertEquals(input.indexOf('(A +'), root.start)
+        assertEquals(root.start + root.matchedString.length(), root.end)
+        assertEquals(root.matchedString.length(), root.length)
+        assertEquals(root.start, root.fullStart)
+        assertEquals(root.end, root.fullEnd)
+        assertEquals(root.matchedString, input.substring(root.start, root.end))
+
+        BalancedGroup nodeB = root.children[0]
+        assertEquals('(B * C)', nodeB.matchedString)
+        assertSame(root, nodeB.parent)
+        assertEquals(1, nodeB.depth)
+        assertEquals(input.indexOf('(B * C)'), nodeB.start)
+
+        BalancedGroup nodeD = root.children[1]
+        assertEquals('(D(E))', nodeD.matchedString)
+        assertSame(root, nodeD.parent)
+
+        BalancedGroup nodeE = nodeD.children[0]
+        assertEquals('(E)', nodeE.matchedString)
+        assertSame(nodeD, nodeE.parent)
+        assertEquals(2, nodeE.depth)
+    }
+
+    @Test
+    void testLeafChildrenImmutable() {
+        BalancedGroup leaf = 'Leaf: ()'.findBalancedGroups('\\(', '\\)')[0]
+
+        assertTrue(leaf.children.isEmpty())
+        assertThrows(UnsupportedOperationException, () -> 
leaf.children.add(null))
+    }
+
+    @Test
+    void testDanglingOpenRescuesCompletedChildren() {
+        // .NET strict (?(Open)(?!)) would fail the whole match; we salvage 
closed spans.
+        List<BalancedGroup> roots = '(A (B) (C(D)'.findBalancedGroups('\\(', 
'\\)')
+
+        assertEquals(2, roots.size())
+        assertEquals('(B)', roots[0].matchedString)
+        assertEquals('(D)', roots[1].matchedString)
+        assertNull(roots[0].parent)
+        assertNull(roots[1].parent)
+        assertEquals(0, roots[0].depth)
+    }
+
+    @Test
+    void testDanglingCloseIgnored() {
+        List<BalancedGroup> roots = 'A ) B (C) D )'.findBalancedGroups('\\(', 
'\\)')
+
+        assertEquals(1, roots.size())
+        assertEquals('(C)', roots[0].matchedString)
+    }
+
+    @Test
+    void testIncludeEdgesFalseStripsDelimitersLikeNetBalancingCapture() {
+        // .NET (?<Content-Open>\)) captures the interval between Open and 
close, not the delimiters.
+        String input = 'func(arg1, arg2)'
+        def options = 
BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+        List<BalancedGroup> roots = input.findBalancedGroups('\\(', '\\)', 
options)
+
+        assertEquals(1, roots.size())
+        BalancedGroup g = roots[0]
+        assertEquals('arg1, arg2', g.matchedString)
+        assertEquals(5, g.start)
+        assertEquals(15, g.end)
+        assertEquals(4, g.fullStart)  // '('
+        assertEquals(16, g.fullEnd)   // after ')'
+        assertEquals('(arg1, arg2)', input.substring(g.fullStart, g.fullEnd))
+        assertEquals('arg1, arg2', input.substring(g.start, g.end))
+    }
+
+    @Test
+    void testIncludeEdgesFalseWithEmptyContent() {
+        def options = 
BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+        List<BalancedGroup> roots = 'Empty: ()'.findBalancedGroups('\\(', 
'\\)', options)
+
+        assertEquals(1, roots.size())
+        assertEquals('', roots[0].matchedString)
+        assertEquals(0, roots[0].length)
+        assertEquals(roots[0].start, roots[0].end)
+        assertTrue(roots[0].fullEnd > roots[0].fullStart)
+    }
+
+    @Test
+    void testIgnoreRegexSkipsLiterals() {
+        String code = 'if(true) { String s = "}"; }'
+        def options = BalancedGroup.MatchOptions.defaults()
+            .withIgnoreRegex('"(?:\\\\.|[^"\\\\])*"')
+
+        List<BalancedGroup> roots = code.findBalancedGroups('\\{', '\\}', 
options)
+
+        assertEquals(1, roots.size())
+        assertEquals('{ String s = "}"; }', roots[0].matchedString)
+    }
+
+    @Test
+    void testBlankIgnoreRegexTreatedAsAbsent() {
+        def options = BalancedGroup.MatchOptions.defaults().withIgnoreRegex('  
 ')
+        List<BalancedGroup> roots = '(Test)'.findBalancedGroups('\\(', '\\)', 
options)
+
+        assertEquals('(Test)', roots[0].matchedString)
+    }
+
+    @Test
+    void testNullOptionsUsesDefaults() {
+        List<BalancedGroup> roots = '(A)'.findBalancedGroups('\\(', '\\)', 
null)
+        assertEquals('(A)', roots[0].matchedString)
+
+        List<BalancedGroup> rootsOverload = '[B]'.findBalancedGroups('\\[', 
'\\]')
+        assertEquals('[B]', rootsOverload[0].matchedString)
+    }
+
+    @Test
+    void testNestedHtmlDivsWithAttributes() {
+        String html = '''
+            <div id="app" class="container">
+                <div class="header" style="color: red;">Header</div>
+                <div class="body">
+                    <div class="content">Content</div>
+                </div>
+            </div>
+            <div id="footer">Footer</div>
+            '''
+
+        List<BalancedGroup> roots = html.findBalancedGroups('<div\\b[^>]*>', 
'</div>')
+
+        assertEquals(2, roots.size())
+
+        BalancedGroup appDiv = roots[0]
+        assertTrue(appDiv.matchedString.startsWith('<div id="app"'))
+        assertEquals(2, appDiv.children.size())
+        assertEquals(html.substring(appDiv.start, appDiv.end), 
appDiv.matchedString)
+
+        assertEquals('<div class="header" style="color: red;">Header</div>',
+            appDiv.children[0].matchedString)
+
+        BalancedGroup bodyDiv = appDiv.children[1]
+        assertEquals(1, bodyDiv.children.size())
+        assertEquals('<div class="content">Content</div>',
+            bodyDiv.children[0].matchedString)
+    }
+
+    @Test
+    void testHtmlIncludeEdgesFalse() {
+        String html = '<div class="wrapper">\n  <p>Hello World</p>\n</div>'
+        def options = 
BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+
+        List<BalancedGroup> roots = html.findBalancedGroups('<div\\b[^>]*>', 
'</div>', options)
+
+        assertEquals(1, roots.size())
+        assertEquals('\n  <p>Hello World</p>\n', roots[0].matchedString)
+        assertTrue(roots[0].fullStart < roots[0].start)
+        assertTrue(roots[0].fullEnd > roots[0].end)
+    }
+
+    @Test
+    void testIgnoreHtmlComments() {
+        String html = '''
+            <div id="main">
+                <!-- <div class="fake">not real</div> -->
+                <div class="real">Valid Content</div>
+            </div>
+            '''
+
+        def options = BalancedGroup.MatchOptions.defaults()
+            .withIgnoreRegex('(?s)<!--.*?-->')
+
+        List<BalancedGroup> roots = html.findBalancedGroups('<div\\b[^>]*>', 
'</div>', options)
+
+        assertEquals(1, roots.size())
+
+        BalancedGroup mainDiv = roots[0]
+        assertTrue(mainDiv.matchedString.contains('Valid Content'))
+        assertEquals(1, mainDiv.children.size())
+        assertEquals('<div class="real">Valid Content</div>', 
mainDiv.children[0].matchedString)
+    }
+
+    @Test
+    void testIgnoreScriptBlocksContainingFakeTags() {
+        String html = '''
+            <div id="react-root">
+                <script type="text/javascript">
+                    const fakeHtml = "</div>";
+                </script>
+                <div class="app">App Ready</div>
+            </div>
+            '''
+
+        def options = BalancedGroup.MatchOptions.defaults()
+            .withIgnoreRegex('(?s)<script\\b[^>]*>.*?</script>')
+
+        List<BalancedGroup> roots = html.findBalancedGroups('<div\\b[^>]*>', 
'</div>', options)
+
+        assertEquals(1, roots.size())
+        assertEquals(1, roots[0].children.size())
+        assertEquals('<div class="app">App Ready</div>', 
roots[0].children[0].matchedString)
+    }
+
+    @Test
+    void testIgnoreSelfClosingTags() {
+        String html = '''
+            <div id="parent">
+                <div class="placeholder" /> <div class="child">I am child</div>
+            </div>
+            '''
+
+        def options = BalancedGroup.MatchOptions.defaults()
+            .withIgnoreRegex('<div\\b[^>]*/>')
+
+        List<BalancedGroup> roots = html.findBalancedGroups('<div\\b[^>]*>', 
'</div>', options)
+
+        assertEquals(1, roots.size())
+        assertEquals(1, roots[0].children.size())
+        assertEquals('<div class="child">I am child</div>', 
roots[0].children[0].matchedString)
+    }
+
+    @Test
+    void testMalformedOuterHtmlRescuesInner() {
+        String html = '''
+            <div class="broken-wrapper">
+                <div class="lost-tag"> <div class="target">I am safe!</div>
+            '''
+
+        List<BalancedGroup> roots = html.findBalancedGroups('<div\\b[^>]*>', 
'</div>')
+
+        assertEquals(1, roots.size())
+        assertEquals('<div class="target">I am safe!</div>', 
roots[0].matchedString)
+    }
+
+    @Test
+    void testMultipleTopLevelGroups() {
+        String input = '(a)(b(c))(d)'
+        List<BalancedGroup> roots = input.findBalancedGroups('\\(', '\\)')
+
+        assertEquals(3, roots.size())
+        assertEquals('(a)', roots[0].matchedString)
+        assertEquals('(b(c))', roots[1].matchedString)
+        assertEquals('(c)', roots[1].children[0].matchedString)
+        assertEquals('(d)', roots[2].matchedString)
+
+        // Successive top-level spans are ordered and non-overlapping
+        assertTrue(roots[0].fullEnd <= roots[1].fullStart)
+        assertTrue(roots[1].fullEnd <= roots[2].fullStart)
+        roots.each { g ->
+            assertEquals(input.substring(g.start, g.end), g.matchedString)
+        }
+    }
+
+    @Test
+    void testReturnedListIsUnmodifiable() {
+        List<BalancedGroup> roots = '(a)'.findBalancedGroups('\\(', '\\)')
+        assertThrows(UnsupportedOperationException, () -> roots.add(null))
+    }
+
+    @Test
+    void testCharSequenceOtherThanString() {
+        CharSequence cs = new StringBuilder('pre(x(y))post')
+        List<BalancedGroup> roots = cs.findBalancedGroups('\\(', '\\)')
+
+        assertEquals(1, roots.size())
+        assertEquals('(x(y))', roots[0].matchedString)
+        assertEquals('(y)', roots[0].children[0].matchedString)
+        assertEquals(3, roots[0].start)
+        assertEquals(9, roots[0].end)
+    }
+
+    @Test
+    void testDeepNestingDepthAndOffsets() {
+        String input = '((((a))))'
+        BalancedGroup root = input.findBalancedGroups('\\(', '\\)')[0]
+
+        assertEquals(0, root.depth)
+        BalancedGroup n = root
+        int expectedDepth = 0
+        while (!n.children.isEmpty()) {
+            n = n.children[0]
+            expectedDepth++
+            assertEquals(expectedDepth, n.depth)
+            assertEquals(input.substring(n.start, n.end), n.matchedString)
+        }
+        assertEquals('(a)', n.matchedString)
+        assertEquals(3, n.depth) // ((((a)))) → four nodes, depths 0..3
+    }
+
+    @Test
+    void testRepeatedCallsReuseTokenizerCache() {
+        // Behavioural smoke test: many identical calls must stay correct 
(cache hit path).
+        100.times {
+            def roots = 'x(a(b))y(c)'.findBalancedGroups('\\(', '\\)')
+            assertEquals(2, roots.size())
+            assertEquals('(a(b))', roots[0].matchedString)
+            assertEquals('(b)', roots[0].children[0].matchedString)
+            assertEquals('(c)', roots[1].matchedString)
+        }
+    }
+
+    @Test
+    void testAdjacentAndNestedMixLikeNetCaptureCollectionUseCases() {
+        // Classic .NET sample shape: <abc><mno<xyz>> as angle-bracket pairs
+        String input = '<abc><mno<xyz>>'
+        List<BalancedGroup> roots = input.findBalancedGroups('<', '>')
+
+        assertEquals(2, roots.size())
+        assertEquals('<abc>', roots[0].matchedString)
+        assertTrue(roots[0].children.isEmpty())
+        assertEquals('<mno<xyz>>', roots[1].matchedString)
+        assertEquals(1, roots[1].children.size())
+        assertEquals('<xyz>', roots[1].children[0].matchedString)
+    }
+
+
+    @Test
+    void testBalancedGroupFindDirectApiAndToString() {
+        List<BalancedGroup> viaStatic = BalancedGroup.find('(x)', '\\(', '\\)')
+        assertEquals(1, viaStatic.size())
+        assertEquals('(x)', viaStatic[0].toString())
+        assertEquals('(x)', viaStatic[0].matchedString)
+
+        List<BalancedGroup> viaStaticOpts = BalancedGroup.find(
+            'f(y)', '\\(', '\\)', 
BalancedGroup.MatchOptions.defaults().withIncludeEdges(false))
+        assertEquals('y', viaStaticOpts[0].matchedString)
+    }
+
+    @Test
+    void testBalancedGroupFindNullTextThrows() {
+        assertThrows(NullPointerException, () -> BalancedGroup.find(null, 
'\\(', '\\)'))
+        assertThrows(NullPointerException, () -> BalancedGroup.find(null, 
'\\(', '\\)', null))
+    }
+
+    @Test
+    void testInvalidPatternThrowsPatternSyntaxException() {
+        assertThrows(PatternSyntaxException, () -> 
'(a)'.findBalancedGroups('[', '\\)'))
+        assertThrows(PatternSyntaxException, () -> BalancedGroup.find('a', 
'\\(', '*'))
+        assertThrows(PatternSyntaxException,
+            () -> BalancedGroup.find('{}', '\\{', '\\}',
+                BalancedGroup.MatchOptions.defaults().withIgnoreRegex('[')))
+    }
+
+    @Test
+    void testMatchOptionsWithersPreserveOtherFields() {
+        def base = BalancedGroup.MatchOptions.defaults()
+        assertNull(base.ignoreRegex())
+        assertTrue(base.includeEdges())
+
+        def noEdges = base.withIncludeEdges(false)
+        assertFalse(noEdges.includeEdges())
+        assertNull(noEdges.ignoreRegex())
+
+        def withIgnore = noEdges.withIgnoreRegex('"(?:\\\\.|[^"\\\\])*"')
+        assertFalse(withIgnore.includeEdges())
+        assertEquals('"(?:\\\\.|[^"\\\\])*"', withIgnore.ignoreRegex())
+
+        def edgesBack = withIgnore.withIncludeEdges(true)
+        assertTrue(edgesBack.includeEdges())
+        assertEquals(withIgnore.ignoreRegex(), edgesBack.ignoreRegex())
+
+        def cleared = edgesBack.withIgnoreRegex(null)
+        assertNull(cleared.ignoreRegex())
+        assertTrue(cleared.includeEdges())
+    }
+
+    @Test
+    void testOnlyDanglingOpensYieldEmptySnapshot() {
+        // Orphan rescue with no completed children: addAllChildren 
early-return path
+        assertTrue('(((('.findBalancedGroups('\\(', '\\)').isEmpty())
+        assertTrue(BalancedGroup.find('open only (', '\\(', '\\)').isEmpty())
+    }
+
+    @Test
+    void testCompletedGroupThenDanglingOpen() {
+        List<BalancedGroup> roots = 
'(done)(still-open'.findBalancedGroups('\\(', '\\)')
+        assertEquals(1, roots.size())
+        assertEquals('(done)', roots[0].matchedString)
+    }
+
+    @Test
+    void testOrphanRescueMergesIntoParentThatAlreadyHasChildren() {
+        // Super-root / outer frames already holding children when a dangling 
frame is popped
+        // exercises PendingGroup.addAllChildren merge branch (addAll vs list 
steal).
+        String input = '(outer (first) (second-open (inner)'
+        List<BalancedGroup> roots = input.findBalancedGroups('\\(', '\\)')
+
+        assertEquals(2, roots.size())
+        assertEquals('(first)', roots[0].matchedString)
+        assertEquals('(inner)', roots[1].matchedString)
+    }
+
+    @Test
+    void testNestedThenSiblingAfterClose() {
+        String input = '(a(b))(c)'
+        List<BalancedGroup> roots = input.findBalancedGroups('\\(', '\\)')
+        assertEquals(2, roots.size())
+        assertEquals('(a(b))', roots[0].matchedString)
+        assertEquals('(b)', roots[0].children[0].matchedString)
+        assertEquals('(c)', roots[1].matchedString)
+        // second addChild on the same pending frame (super-root)
+        assertEquals(0, roots[0].depth)
+        assertEquals(0, roots[1].depth)
+    }
+
+    @Test
+    void testIgnoreRegexCacheKeyDistinctFromNoIgnore() {
+        String text = '{ "x": 1 }'
+        def withIgnore = BalancedGroup.MatchOptions.defaults()
+            .withIgnoreRegex('"(?:\\\\.|[^"\\\\])*"')
+        // Without ignore, the quote-embedded content is not special; braces 
still balance.
+        assertEquals(1, text.findBalancedGroups('\\{', '\\}').size())
+        assertEquals(1, text.findBalancedGroups('\\{', '\\}', 
withIgnore).size())
+        // Hit both tokenizer cache keys (with and without IGNORE alternative)
+        assertEquals(1, text.findBalancedGroups('\\{', '\\}').size())
+        assertEquals(1, text.findBalancedGroups('\\{', '\\}', 
withIgnore).size())
+    }
+
+    @Test
+    void testTokenizerCacheEvictionBeyondLimit() {
+        // TOKENIZER_CACHE_LIMIT is 64; filling unique ignore keys exercises 
removeEldestEntry.
+        def base = BalancedGroup.MatchOptions.defaults()
+        70.times { int i ->
+            def opts = base.withIgnoreRegex("never_match_${i}_xyz")
+            List<BalancedGroup> roots = '(ok)'.findBalancedGroups('\\(', 
'\\)', opts)
+            assertEquals(1, roots.size())
+            assertEquals('(ok)', roots[0].matchedString)
+        }
+        // Still correct after eviction churn
+        assertEquals('(ok)', '(ok)'.findBalancedGroups('\\(', 
'\\)')[0].matchedString)
+    }
+
+    @Test
+    void testZeroWidthOpenLookaheadWithIncludeEdgesFalse() {
+        // Zero-width OPEN then CLOSE: interior is the span between openEnd 
and closeStart.
+        def opts = 
BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+        List<BalancedGroup> roots = BalancedGroup.find('ab', '(?=a)', 'b', 
opts)
+        assertEquals(1, roots.size())
+        assertEquals('a', roots[0].matchedString)
+        assertEquals(roots[0].start, roots[0].fullStart) // open was 
zero-width at fullStart
+        assertTrue(roots[0].fullEnd > roots[0].end || roots[0].fullEnd >= 
roots[0].end)
+    }
+
+    @Test
+    void testCloseWhenOpenStackIsOnlyVirtualRootIsIgnored() {
+        // Explicit extra closers before any open — CLOSE branch with 
stack.size() == 1
+        assertTrue(')))'.findBalancedGroups('\\(', '\\)').isEmpty())
+        assertEquals(['(x)'], ')))(x)))'.findBalancedGroups('\\(', 
'\\)')*.matchedString)
+    }
+
+    @Test
+    void testGStringCharSequenceSource() {
+        def name = 'world'
+        GString g = "(hello $name)"
+        List<BalancedGroup> roots = g.findBalancedGroups('\\(', '\\)')
+        assertEquals(1, roots.size())
+        assertEquals('(hello world)', roots[0].matchedString)
+        assertEquals(g.toString(), roots[0].matchedString)
+        assertEquals(0, roots[0].start)
+        assertEquals(g.toString().length(), roots[0].end)
+    }
+
+    @Test
+    void testIncludeEdgesTrueExplicitOptions() {
+        def opts = BalancedGroup.MatchOptions.defaults().withIncludeEdges(true)
+        BalancedGroup g = '(z)'.findBalancedGroups('\\(', '\\)', opts)[0]
+        assertEquals('(z)', g.matchedString)
+        assertEquals(g.start, g.fullStart)
+        assertEquals(g.end, g.fullEnd)
+    }
+
+    @Test
+    void testMultipleChildrenOnSameFrameViaAddChild() {
+        // Three completed children under one parent → repeated addChild growth
+        String input = '(p (a) (b) (c))'
+        BalancedGroup root = input.findBalancedGroups('\\(', '\\)')[0]
+        assertEquals(3, root.children.size())
+        assertEquals(['(a)', '(b)', '(c)'], root.children*.matchedString)
+        root.children.each { child ->
+            assertSame(root, child.parent)
+            assertEquals(1, child.depth)
+        }
+    }
+
+    @Test
+    void testDeepOrphanRescueChain() {
+        // Several unfinished opens each carrying a completed child
+        String input = '(a (b)) (c (d (e)'
+        List<BalancedGroup> roots = input.findBalancedGroups('\\(', '\\)')
+        assertEquals(2, roots.size())
+        assertEquals('(a (b))', roots[0].matchedString)
+        assertEquals('(b)', roots[0].children[0].matchedString)
+        assertEquals('(e)', roots[1].matchedString)
+    }
+
+    @Test
+    void testNullIgnoreRegexOnOptionsIsAbsent() {
+        def opts = BalancedGroup.MatchOptions.defaults().withIgnoreRegex(null)
+        assertNull(opts.ignoreRegex())
+        assertEquals('(t)', '(t)'.findBalancedGroups('\\(', '\\)', 
opts)[0].matchedString)
+    }
+
+    @Test
+    void testEmptyInput() {
+        assertTrue(''.findBalancedGroups('\\(', '\\)').isEmpty())
+        assertTrue(BalancedGroup.find('', '\\(', '\\)').isEmpty())
+    }
+}
diff --git 
a/src/test/groovy/groovy/util/regex/BalancedGroupConstructorTest.groovy 
b/src/test/groovy/groovy/util/regex/BalancedGroupConstructorTest.groovy
new file mode 100644
index 0000000000..66dc682259
--- /dev/null
+++ b/src/test/groovy/groovy/util/regex/BalancedGroupConstructorTest.groovy
@@ -0,0 +1,100 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.util.regex
+
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertThrows
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Package-private constructor contracts for {@link BalancedGroup}.
+ * Lives in the same package so tests can exercise the package-private
+ * constructors without widening them to public API.
+ *
+ * User-facing behaviour is covered by {@code bugs.Groovy12133}.
+ */
+final class BalancedGroupConstructorTest {
+
+    @Test
+    void testConstructorWiresParentAndRejectsReparenting() {
+        BalancedGroup child = new BalancedGroup('(E)', null)
+        BalancedGroup parent = new BalancedGroup('(D(E))', [child])
+
+        assertSame(parent, child.parent)
+        assertEquals(1, parent.children.size())
+        assertSame(child, parent.children[0])
+        assertEquals(0, child.start)
+        assertEquals(3, child.end)
+        assertEquals(0, parent.depth)
+        assertEquals(1, child.depth)
+
+        assertThrows(IllegalArgumentException, () -> new 
BalancedGroup('other', [child]))
+        assertThrows(NullPointerException, () -> new BalancedGroup(null, null))
+        assertThrows(IllegalArgumentException, () -> new BalancedGroup('ab', 
0, 1, 0, 1, null)) // length mismatch
+        assertThrows(IllegalArgumentException, () -> new BalancedGroup('a', 
-1, 1, 0, 1, null))
+    }
+
+    @Test
+    void testConstructorRejectsInvalidFullRangeAndEndBeforeStart() {
+        assertThrows(IllegalArgumentException, () -> new BalancedGroup('a', 3, 
1, 0, 1, null))
+        assertThrows(IllegalArgumentException, () -> new BalancedGroup('a', 0, 
1, 3, 1, null))
+        assertThrows(IllegalArgumentException, () -> new BalancedGroup('a', 0, 
1, -1, 0, null))
+        assertThrows(NullPointerException, () -> new BalancedGroup('a', 0, 1, 
0, 1, [null]))
+    }
+
+    @Test
+    void testConstructorAcceptsEmptyListAndAbsoluteOffsets() {
+        BalancedGroup leaf = new BalancedGroup('leaf', [])
+        assertTrue(leaf.children.isEmpty())
+        assertEquals(0, leaf.start)
+        assertEquals(4, leaf.end)
+        assertEquals(0, leaf.fullStart)
+        assertEquals(4, leaf.fullEnd)
+        assertEquals(0, leaf.depth)
+
+        BalancedGroup absolute = new BalancedGroup('xy', 10, 12, 8, 15, null)
+        assertEquals('xy', absolute.matchedString)
+        assertEquals(10, absolute.start)
+        assertEquals(12, absolute.end)
+        assertEquals(8, absolute.fullStart)
+        assertEquals(15, absolute.fullEnd)
+        assertEquals(2, absolute.length)
+        assertEquals(0, absolute.depth)
+    }
+
+    @Test
+    void testDepthCascadesWhenParentIsLaterAttached() {
+        // Mirrors bottom-up assembly in BalancedGroup.find: inner nodes are
+        // constructed first (depth relative to a temporary root), then the
+        // outer node attaches them and must recompute descendant depths.
+        BalancedGroup grandChild = new BalancedGroup('(E)', null)
+        BalancedGroup child = new BalancedGroup('(D(E))', [grandChild])
+        assertEquals(1, grandChild.depth)
+
+        BalancedGroup root = new BalancedGroup('(A(D(E)))', [child])
+        assertEquals(0, root.depth)
+        assertEquals(1, child.depth)
+        assertEquals(2, grandChild.depth)
+        assertSame(root, child.parent)
+        assertSame(child, grandChild.parent)
+    }
+}

Reply via email to