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 <[email protected]>
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))
+    }
+}

Reply via email to