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)) + } +}
