You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/07/12 16:30:15 UTC

(groovy) branch GROOVY-12133 updated (69461ff0a3 -> a60c37ae28)

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

asf-gitbox-commits pushed a change to branch GROOVY-12133
in repository https://gitbox.apache.org/repos/asf/groovy.git


    omit 69461ff0a3 GROOVY-12133: Implement findBalanced utility to support Regex Balanced Groups
     new a60c37ae28 GROOVY-12133: Implement findBalanced utility to support Regex Balanced Groups

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (69461ff0a3)
            \
             N -- N -- N   refs/heads/GROOVY-12133 (a60c37ae28)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 src/main/java/groovy/util/regex/BalancedGroup.java |  96 +++++---
 .../org/codehaus/groovy/runtime/RegexSupport.java  | 170 ++++++-------
 .../groovy/runtime/StringGroovyMethods.java        |  59 ++++-
 src/test/groovy/bugs/Groovy12133.groovy            | 270 +++++++++++----------
 4 files changed, 330 insertions(+), 265 deletions(-)


(groovy) 01/01: GROOVY-12133: Implement findBalanced utility to support Regex Balanced Groups

Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12133
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit a60c37ae28cb9e0dffe1f7ee31971f13f9e42a7b
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sun Jul 5 16:58:45 2026 +0900

    GROOVY-12133: Implement findBalanced utility to support Regex Balanced Groups
---
 src/main/java/groovy/util/regex/BalancedGroup.java | 152 +++++++++++
 .../org/codehaus/groovy/runtime/RegexSupport.java  | 133 +++++++++-
 .../groovy/runtime/StringGroovyMethods.java        |  66 +++++
 src/test/groovy/bugs/Groovy12133.groovy            | 293 +++++++++++++++++++++
 4 files changed, 641 insertions(+), 3 deletions(-)

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..3539288451
--- /dev/null
+++ b/src/main/java/groovy/util/regex/BalancedGroup.java
@@ -0,0 +1,152 @@
+/*
+ *  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.List;
+import java.util.Objects;
+
+/**
+ * A node in the tree of balanced groups extracted by
+ * {@link org.codehaus.groovy.runtime.StringGroovyMethods#findBalanced(CharSequence, String, String)}.
+ * <p>
+ * Each node holds the matched text for one balanced span and the list of
+ * immediately nested balanced spans. Parent links are established when a node
+ * is constructed as a child of another node; root nodes have a {@code null} parent.
+ * </p>
+ *
+ * @since 6.0.0
+ */
+public final class BalancedGroup {
+    private final String matchedString;
+    private final List<BalancedGroup> children;
+    private BalancedGroup parent;
+
+    /**
+     * Constructs a node with the given matched text and immediate children.
+     * <p>
+     * Each child is linked to this node as its parent. The children list is
+     * copied and made unmodifiable.
+     * </p>
+     *
+     * @param matchedString the text captured for this group (never {@code null});
+     *                      may include or exclude boundary delimiters depending on
+     *                      {@link MatchOptions#includeEdges()}
+     * @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
+     */
+    public BalancedGroup(String matchedString, List<BalancedGroup> children) {
+        this.matchedString = Objects.requireNonNull(matchedString, "matchedString");
+        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;
+            }
+        }
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * Returns the matched text of this group.
+     *
+     * @return the same value as {@link #getMatchedString()}
+     */
+    @Override
+    public String toString() {
+        return matchedString;
+    }
+
+    /**
+     * 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
+     * @param includeEdges {@code true} to keep opening/closing delimiters in
+     *                     {@link BalancedGroup#getMatchedString()}; {@code false} to strip them
+     *
+     * @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/RegexSupport.java b/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java
index 4e58158dc1..e2371b3acd 100644
--- a/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java
+++ b/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java
@@ -18,20 +18,147 @@
  */
 package org.codehaus.groovy.runtime;
 
+import groovy.util.regex.BalancedGroup;
+import groovy.util.regex.BalancedGroup.MatchOptions;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.Objects;
 import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
- * Used to store the last regex match.
+ * Internal helpers for regular expressions, including the last-matcher
+ * ThreadLocal and balanced-group extraction used by the GDK.
  */
 public class RegexSupport {
+    /**
+     * Finds balanced groups using {@link MatchOptions#defaults()}.
+     *
+     * @see #findBalanced(CharSequence, String, String, MatchOptions)
+     */
+    static List<BalancedGroup> findBalanced(CharSequence self, String openRegex, String closeRegex) {
+        return findBalanced(self, openRegex, closeRegex, MatchOptions.defaults());
+    }
+
+    /**
+     * Finds balanced (nested) groups in {@code self} and returns them as a forest of
+     * {@link BalancedGroup} nodes.
+     * <p>
+     * Scanning is a single left-to-right pass over matches of a combined tokenizer
+     * regex ({@code IGNORE}? / {@code OPEN} / {@code CLOSE}), using a stack of open
+     * spans. Nesting is resolved by the stack rather than by recursive regex, so
+     * balancing itself does not introduce ReDoS; the cost is proportional to the
+     * number of tokenizer matches (and to the cost of the user-supplied patterns).
+     * </p>
+     * <p>
+     * Unmatched closing delimiters are ignored. Unclosed opening delimiters are
+     * dropped at the end of the input, but any groups already completed inside them
+     * are promoted to the next outer level ("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 self       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 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 supplied pattern is not a valid Java regex
+     */
+    static List<BalancedGroup> findBalanced(CharSequence self, String openRegex, String closeRegex, MatchOptions options) {
+        Objects.requireNonNull(self, "self 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();
+
+        // Alternation order is significant: IGNORE wins over OPEN, OPEN over CLOSE.
+        String tokenizer = hasIgnoreRule
+            ? "(?<IGNORE>" + ignoreRegex + ")|(?<OPEN>" + openRegex + ")|(?<CLOSE>" + closeRegex + ")"
+            : "(?<OPEN>" + openRegex + ")|(?<CLOSE>" + closeRegex + ")";
+
+        Matcher matcher = Pattern.compile(tokenizer).matcher(self);
+
+        // Virtual super-root (indices unused) 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();
 
-    private static final ThreadLocal CURRENT_MATCHER = new ThreadLocal();
+                int startIdx = includeEdges ? openGroup.openStart : openGroup.openEnd;
+                int endIdx = includeEdges ? matcher.end() : matcher.start();
+                String matchStr = startIdx <= endIdx
+                    ? self.subSequence(startIdx, endIdx).toString()
+                    : "";
 
+                // Constructor wires child → parent links.
+                BalancedGroup completedNode = new BalancedGroup(matchStr, openGroup.completedChildren);
+                stack.peek().completedChildren.add(completedNode);
+            }
+        }
+
+        // Promote completed children out of any still-open (dangling) frames.
+        while (stack.size() > 1) {
+            PendingGroup dangling = stack.pop();
+            stack.peek().completedChildren.addAll(dangling.completedChildren);
+        }
+
+        return List.copyOf(stack.peek().completedChildren);
+    }
+
+    /**
+     * Frame for an as-yet unmatched opening delimiter during the scan.
+     */
+    private static final class PendingGroup {
+        final int openStart;
+        final int openEnd;
+        final List<BalancedGroup> completedChildren = new ArrayList<>();
+
+        PendingGroup(int openStart, int openEnd) {
+            this.openStart = openStart;
+            this.openEnd = openEnd;
+        }
+    }
+
+    /**
+     * Returns the last regex matcher used in the current thread.
+     */
     public static Matcher getLastMatcher() {
-        return (Matcher) CURRENT_MATCHER.get();
+        return CURRENT_MATCHER.get();
     }
 
+    /**
+     * Sets the last regex matcher used in the current thread.
+     */
     public static void setLastMatcher(Matcher matcher) {
         CURRENT_MATCHER.set(matcher);
     }
+
+
+    private static final ThreadLocal<Matcher> CURRENT_MATCHER = new ThreadLocal<>();
 }
diff --git a/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java b/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
index 2d7f8248ae..a30628957c 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;
@@ -4447,4 +4448,69 @@ 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).
+     * <pre class="groovyTestCase">
+     * def roots = 'Root: (A + (B * C) + (D(E)))'.findBalanced(/\(/, /\)/)
+     * 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)'
+     * </pre>
+     * <p>
+     * Unmatched closers are ignored; unclosed openers are dropped but completed
+     * groups nested inside them are still returned.
+     * </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 #findBalanced(CharSequence, String, String, BalancedGroup.MatchOptions)
+     * @since 6.0.0
+     */
+    public static List<BalancedGroup> findBalanced(final CharSequence self, final String openRegex, final String closeRegex) {
+        return RegexSupport.findBalanced(self, openRegex, closeRegex);
+    }
+
+    /**
+     * Finds balanced (nested) groups in this CharSequence with the given options.
+     * <pre class="groovyTestCase">
+     * import groovy.util.regex.BalancedGroup
+     * def opts = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+     * assert 'func(arg1, arg2)'.findBalanced(/\(/, /\)/, opts)[0].matchedString == 'arg1, arg2'
+     *
+     * def code = 'if (true) { String s = "}"; }'
+     * def ignoreStrings = BalancedGroup.MatchOptions.defaults()
+     *     .withIgnoreRegex(/"(?:\\.|[^"\\])*"/)
+     * assert code.findBalanced(/\{/, /\}/, ignoreStrings)[0].matchedString == '{ String s = "}"; }'
+     * </pre>
+     * <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 #findBalanced(CharSequence, String, String)
+     * @since 6.0.0
+     */
+    public static List<BalancedGroup> findBalanced(final CharSequence self, final String openRegex, final String closeRegex,
+                                                   final BalancedGroup.MatchOptions options) {
+        return RegexSupport.findBalanced(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..9a222f05f5
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12133.groovy
@@ -0,0 +1,293 @@
+/*
+ *  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 static org.junit.jupiter.api.Assertions.assertEquals
+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
+
+final class Groovy12133 {
+
+    @Test
+    void testNullArgumentsThrowNpe() {
+        assertThrows(NullPointerException, () -> StringGroovyMethods.findBalanced(null, '\\(', '\\)'))
+        assertThrows(NullPointerException, () -> StringGroovyMethods.findBalanced('test', null, '\\)'))
+        assertThrows(NullPointerException, () -> StringGroovyMethods.findBalanced('test', '\\(', null))
+    }
+
+    @Test
+    void testEmptyOpenOrCloseRegexRejected() {
+        assertThrows(IllegalArgumentException, () -> 'x'.findBalanced('', '\\)'))
+        assertThrows(IllegalArgumentException, () -> 'x'.findBalanced('\\(', ''))
+    }
+
+    @Test
+    void testNoMatchReturnsEmptyList() {
+        assertTrue('plain text'.findBalanced('\\(', '\\)').isEmpty())
+    }
+
+    @Test
+    void testPerfectlyNestedBuildsTree() {
+        String input = 'Root: (A + (B * C) + (D(E)))'
+        List<BalancedGroup> roots = input.findBalanced('\\(', '\\)')
+
+        assertEquals(1, roots.size())
+
+        BalancedGroup root = roots[0]
+        assertEquals('(A + (B * C) + (D(E)))', root.matchedString)
+        assertNull(root.parent)
+        assertEquals(2, root.children.size())
+
+        BalancedGroup nodeB = root.children[0]
+        assertEquals('(B * C)', nodeB.matchedString)
+        assertSame(root, nodeB.parent)
+
+        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)
+    }
+
+    @Test
+    void testLeafChildrenImmutable() {
+        BalancedGroup leaf = 'Leaf: ()'.findBalanced('\\(', '\\)')[0]
+
+        assertTrue(leaf.children.isEmpty())
+        assertThrows(UnsupportedOperationException, () -> leaf.children.add(null))
+    }
+
+    @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])
+
+        assertThrows(IllegalArgumentException, () -> new BalancedGroup('other', [child]))
+        assertThrows(NullPointerException, () -> new BalancedGroup(null, null))
+    }
+
+    @Test
+    void testDanglingOpenRescuesCompletedChildren() {
+        List<BalancedGroup> roots = '(A (B) (C(D)'.findBalanced('\\(', '\\)')
+
+        assertEquals(2, roots.size())
+        assertEquals('(B)', roots[0].matchedString)
+        assertEquals('(D)', roots[1].matchedString)
+        assertNull(roots[0].parent)
+        assertNull(roots[1].parent)
+    }
+
+    @Test
+    void testDanglingCloseIgnored() {
+        List<BalancedGroup> roots = 'A ) B (C) D )'.findBalanced('\\(', '\\)')
+
+        assertEquals(1, roots.size())
+        assertEquals('(C)', roots[0].matchedString)
+    }
+
+    @Test
+    void testIncludeEdgesFalseStripsDelimiters() {
+        def options = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+        List<BalancedGroup> roots = 'func(arg1, arg2)'.findBalanced('\\(', '\\)', options)
+
+        assertEquals(1, roots.size())
+        assertEquals('arg1, arg2', roots[0].matchedString)
+    }
+
+    @Test
+    void testIncludeEdgesFalseWithEmptyContent() {
+        def options = BalancedGroup.MatchOptions.defaults().withIncludeEdges(false)
+        List<BalancedGroup> roots = 'Empty: ()'.findBalanced('\\(', '\\)', options)
+
+        assertEquals(1, roots.size())
+        assertEquals('', roots[0].matchedString)
+    }
+
+    @Test
+    void testIgnoreRegexSkipsLiterals() {
+        String code = 'if(true) { String s = "}"; }'
+        def options = BalancedGroup.MatchOptions.defaults()
+            .withIgnoreRegex('"(?:\\\\.|[^"\\\\])*"')
+
+        List<BalancedGroup> roots = code.findBalanced('\\{', '\\}', options)
+
+        assertEquals(1, roots.size())
+        assertEquals('{ String s = "}"; }', roots[0].matchedString)
+    }
+
+    @Test
+    void testBlankIgnoreRegexTreatedAsAbsent() {
+        def options = BalancedGroup.MatchOptions.defaults().withIgnoreRegex('   ')
+        List<BalancedGroup> roots = '(Test)'.findBalanced('\\(', '\\)', options)
+
+        assertEquals('(Test)', roots[0].matchedString)
+    }
+
+    @Test
+    void testNullOptionsUsesDefaults() {
+        List<BalancedGroup> roots = '(A)'.findBalanced('\\(', '\\)', null)
+        assertEquals('(A)', roots[0].matchedString)
+
+        List<BalancedGroup> rootsOverload = '[B]'.findBalanced('\\[', '\\]')
+        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.findBalanced('<div\\b[^>]*>', '</div>')
+
+        assertEquals(2, roots.size())
+
+        BalancedGroup appDiv = roots[0]
+        assertTrue(appDiv.matchedString.startsWith('<div id="app"'))
+        assertEquals(2, appDiv.children.size())
+
+        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.findBalanced('<div\\b[^>]*>', '</div>', options)
+
+        assertEquals(1, roots.size())
+        assertEquals('\n  <p>Hello World</p>\n', roots[0].matchedString)
+    }
+
+    @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.findBalanced('<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.findBalanced('<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.findBalanced('<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.findBalanced('<div\\b[^>]*>', '</div>')
+
+        assertEquals(1, roots.size())
+        assertEquals('<div class="target">I am safe!</div>', roots[0].matchedString)
+    }
+
+    @Test
+    void testMultipleTopLevelGroups() {
+        List<BalancedGroup> roots = '(a)(b(c))(d)'.findBalanced('\\(', '\\)')
+
+        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)
+    }
+
+    @Test
+    void testReturnedListIsUnmodifiable() {
+        List<BalancedGroup> roots = '(a)'.findBalanced('\\(', '\\)')
+        assertThrows(UnsupportedOperationException, () -> roots.add(null))
+    }
+}