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/05 17:19:53 UTC
(groovy) branch GROOVY-12133 updated (ee53ae6a7a -> 7c9954e77b)
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
discard ee53ae6a7a GROOVY-12133: Implement findBalanced utility to support Regex Balanced Groups
new 7c9954e77b 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 (ee53ae6a7a)
\
N -- N -- N refs/heads/GROOVY-12133 (7c9954e77b)
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/test/groovy/bugs/Groovy12133.groovy | 238 ++++++++++++++++++++++++--------
1 file changed, 179 insertions(+), 59 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 7c9954e77bee396af14cc413705089d3b4b14f1d
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
---
.../org/codehaus/groovy/runtime/RegexSupport.java | 263 +++++++++++++++++++-
.../groovy/runtime/StringGroovyMethods.java | 18 ++
src/test/groovy/bugs/Groovy12133.groovy | 273 +++++++++++++++++++++
3 files changed, 549 insertions(+), 5 deletions(-)
diff --git a/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java b/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java
index 4e58158dc1..d2518282b3 100644
--- a/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java
+++ b/src/main/java/org/codehaus/groovy/runtime/RegexSupport.java
@@ -18,20 +18,273 @@
*/
package org.codehaus.groovy.runtime;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+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.
+ * Provides utility methods for working with regular expressions
*/
public class RegexSupport {
-
- private static final ThreadLocal CURRENT_MATCHER = new ThreadLocal();
-
+ /**
+ * 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);
}
+
+ /**
+ * Finds and extracts balanced (nested) groups from the specified text using default matching options.
+ * <p>
+ * This convenience overload invokes {@link #findBalanced(CharSequence, String, String, MatchOptions)}
+ * using {@link MatchOptions#defaults()}, meaning no content is ignored and boundary edges are preserved.
+ * </p>
+ *
+ * @param self the input character sequence to parse
+ * @param openRegex the regular expression defining the opening boundary
+ * @param closeRegex the regular expression defining the closing boundary
+ * @return an unmodifiable list containing the outermost root nodes of the extracted AST
+ * @throws NullPointerException if {@code self}, {@code openRegex}, or {@code closeRegex} is {@code null}
+ * @see #findBalanced(CharSequence, String, String, MatchOptions)
+ *
+ * @since 6.0.0
+ */
+ static List<BalancedGroup> findBalanced(CharSequence self, String openRegex, String closeRegex) {
+ return findBalanced(self, openRegex, closeRegex, MatchOptions.defaults());
+ }
+
+ /**
+ * Finds and extracts balanced (nested) groups from the specified text, constructing a hierarchical
+ * Abstract Syntax Tree (AST) of the results.
+ * <p>
+ * <b>Algorithm Overview:</b>
+ * This method employs a highly optimized, single-pass $O(N)$ scanning algorithm using a virtual-root
+ * stack tracker. It effectively mimics C# Regex balancing groups while eliminating the risk of
+ * Catastrophic Backtracking (ReDoS).
+ * </p>
+ * <p>
+ * <b>Fault Tolerance:</b>
+ * The parser is highly resilient. It will gracefully discard unmatched closing tags and perform an
+ * "orphan rescue" to salvage valid nested structures trapped inside unclosed opening tags.
+ * </p>
+ * <p>
+ * <b>Important:</b> The provided {@code openRegex} and {@code closeRegex} must not define named
+ * capturing groups called {@code <OPEN>}, {@code <CLOSE>}, or {@code <IGNORE>}, as these are reserved
+ * for internal lexical tokenization.
+ * </p>
+ *
+ * @param self the input character sequence to parse
+ * @param openRegex the regular expression defining the opening boundary (e.g., {@code "\\("})
+ * @param closeRegex the regular expression defining the closing boundary (e.g., {@code "\\)"})
+ * @param options the configuration options for the parsing process. If {@code null},
+ * {@link MatchOptions#defaults()} will be applied.
+ * @return an unmodifiable list containing the outermost root nodes of the extracted AST.
+ * Returns an empty list if no valid balanced groups are found.
+ * @throws NullPointerException if {@code self}, {@code openRegex}, or {@code closeRegex} is {@code null}
+ *
+ * @since 6.0.0
+ */
+ 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");
+ MatchOptions actualOptions = options != null ? options : MatchOptions.defaults();
+
+ boolean hasIgnoreRule = actualOptions.ignoreRegex() != null && !actualOptions.ignoreRegex().isBlank();
+
+ StringBuilder combinedRegex = new StringBuilder();
+ if (hasIgnoreRule) {
+ combinedRegex.append("(?<IGNORE>").append(actualOptions.ignoreRegex()).append(")|");
+ }
+ combinedRegex.append("(?<OPEN>").append(openRegex).append(")|")
+ .append("(?<CLOSE>").append(closeRegex).append(")");
+
+ Matcher matcher = Pattern.compile(combinedRegex.toString()).matcher(self);
+
+ // A Deque is utilized as a Pushdown Automaton stack.
+ // We initialize it with a 'Virtual SuperRoot' (-1, -1) to radically simplify the mounting
+ // of top-level root nodes and eliminate complex empty-stack checks.
+ Deque<PendingGroup> stack = new ArrayDeque<>();
+ stack.push(new PendingGroup(-1, -1));
+
+ while (matcher.find()) {
+ // Bypass explicitly ignored patterns (e.g., literals or escape sequences)
+ if (hasIgnoreRule && matcher.group("IGNORE") != null) {
+ continue;
+ }
+
+ if (matcher.group("OPEN") != null) {
+ // Left boundary encountered: push a new pending frame onto the stack
+ stack.push(new PendingGroup(matcher.start(), matcher.end()));
+ } else if (matcher.group("CLOSE") != null) {
+ // Right boundary encountered: pop and finalize if there's a valid open frame
+ // (stack size > 1 ensures we never pop the Virtual SuperRoot)
+ if (stack.size() > 1) {
+ PendingGroup openGroup = stack.pop();
+
+ int startIdx = actualOptions.includeEdges() ? openGroup.openStart : openGroup.openEnd;
+ int endIdx = actualOptions.includeEdges() ? matcher.end() : matcher.start();
+
+ // Edge case prevention: if stripping edges results in negative length, return empty string
+ String matchStr = startIdx <= endIdx ? self.subSequence(startIdx, endIdx).toString() : "";
+
+ // Instantiate the finalized AST node
+ BalancedGroup completedNode = new BalancedGroup(matchStr, openGroup.completedChildren);
+
+ // Establish bidirectional tree linkage for all its children
+ for (BalancedGroup child : openGroup.completedChildren) {
+ child.setParent(completedNode);
+ }
+
+ // Mount the newly completed node onto the pending children list of its immediate parent
+ stack.peek().completedChildren.add(completedNode);
+ }
+ }
+ }
+
+ // Orphan Rescue Mechanism (Fault Tolerance):
+ // If the stack contains more than the SuperRoot after scanning, dangling open tags exist.
+ // We systematically bubble up any successfully completed child nodes trapped inside these
+ // invalid pending groups, salvaging valid subtrees up to the SuperRoot.
+ while (stack.size() > 1) {
+ PendingGroup dangling = stack.pop();
+ stack.peek().completedChildren.addAll(dangling.completedChildren);
+ }
+
+ // The SuperRoot's children collection now strictly contains all valid outermost root nodes.
+ return Collections.unmodifiableList(stack.peek().completedChildren);
+ }
+
+ /**
+ * Configuration options for the balanced regex matching process.
+ * Implemented as an immutable record using the Wither pattern for thread-safe configurations.
+ *
+ * @param ignoreRegex a regular expression defining patterns to be strictly ignored during
+ * lexical tokenization (e.g., escaped boundaries or string literals).
+ * @param includeEdges {@code true} to include the opening and closing boundary tags in the
+ * extracted results; {@code false} to strip them.
+ *
+ * @since 6.0.0
+ */
+ public record MatchOptions(String ignoreRegex, boolean includeEdges) {
+
+ /**
+ * Returns the default matching options: no patterns are ignored, and boundary edges are included.
+ *
+ * @return the default {@code MatchOptions}
+ */
+ public static MatchOptions defaults() {
+ return new MatchOptions(null, true);
+ }
+
+ /**
+ * Creates a new {@code MatchOptions} instance with the specified ignore regex.
+ *
+ * @param ignoreRegex the regular expression of the content to ignore
+ * @return a new {@code MatchOptions} instance
+ */
+ public MatchOptions withIgnoreRegex(String ignoreRegex) {
+ return new MatchOptions(ignoreRegex, this.includeEdges);
+ }
+
+ /**
+ * Creates a new {@code MatchOptions} instance with the specified edge inclusion policy.
+ *
+ * @param includeEdges whether to include the boundary tags
+ * @return a new {@code MatchOptions} instance
+ */
+ public MatchOptions withIncludeEdges(boolean includeEdges) {
+ return new MatchOptions(this.ignoreRegex, includeEdges);
+ }
+ }
+
+ /**
+ * Represents a node within the Abstract Syntax Tree (AST) generated by the balanced regex parser.
+ * Each node corresponds to a successfully paired inner or outer matched group.
+ *
+ * @since 6.0.0
+ */
+ public static final class BalancedGroup {
+ private final String matchedString;
+ private final List<BalancedGroup> children;
+ private BalancedGroup parent; // Node linkage: points to the parent node, or null if it is a root node.
+
+ BalancedGroup(String matchedString, List<BalancedGroup> children) {
+ this.matchedString = matchedString;
+ // Defensive programming: guarantee strict immutability and handle nulls gracefully.
+ if (children == null || children.isEmpty()) {
+ this.children = Collections.emptyList();
+ } else {
+ this.children = List.copyOf(children);
+ }
+ }
+
+ /**
+ * Retrieves the raw string content captured by this balanced group.
+ *
+ * @return the captured string, potentially stripped of its boundaries depending on {@link MatchOptions}
+ */
+ public String getMatchedString() {
+ return matchedString;
+ }
+
+ /**
+ * Retrieves the inner balanced groups deeply nested within this current group.
+ *
+ * @return an unmodifiable list of child nodes. Returns an empty list (never null) if this is a leaf node.
+ */
+ public List<BalancedGroup> getChildren() {
+ return children;
+ }
+
+ /**
+ * Retrieves the parent group that encapsulates this current group.
+ *
+ * @return the parent {@code BalancedGroup}, or {@code null} if this is an outermost (root) node.
+ */
+ public BalancedGroup getParent() {
+ return parent;
+ }
+
+ // Package-private setter for establishing bidirectional tree links during construction.
+ void setParent(BalancedGroup parent) {
+ this.parent = parent;
+ }
+
+ @Override
+ public String toString() {
+ return matchedString;
+ }
+ }
+
+ /**
+ * Internal data structure used to track the state of unclosed groups during the single-pass scan.
+ *
+ * @since 6.0.0
+ */
+ 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;
+ }
+ }
+
+
+ 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 b50f3e631e..19aad45597 100644
--- a/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
@@ -4342,4 +4342,22 @@ public class StringGroovyMethods extends DefaultGroovyMethodsSupport {
return self.toString().toLowerCase(Locale.ROOT).contains(searchString.toString().toLowerCase(Locale.ROOT));
}
+
+ /**
+ * @see RegexSupport#findBalanced(CharSequence, String, String)
+ *
+ * @since 6.0.0
+ */
+ public static List<RegexSupport.BalancedGroup> findBalanced(CharSequence self, String openRegex, String closeRegex) {
+ return RegexSupport.findBalanced(self, openRegex, closeRegex);
+ }
+
+ /**
+ * @see RegexSupport#findBalanced(CharSequence, String, String, RegexSupport.MatchOptions)
+ *
+ * @since 6.0.0
+ */
+ public static List<RegexSupport.BalancedGroup> findBalanced(CharSequence self, String openRegex, String closeRegex, RegexSupport.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..b57848518c
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12133.groovy
@@ -0,0 +1,273 @@
+/*
+ * 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 org.codehaus.groovy.runtime.RegexSupport
+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 should_ThrowException_When_NullArgumentsPassed() {
+ assertThrows(NullPointerException.class, () -> StringGroovyMethods.findBalanced(null, "\\(", "\\)"))
+ assertThrows(NullPointerException.class, () -> StringGroovyMethods.findBalanced("test", null, "\\)"))
+ assertThrows(NullPointerException.class, () -> StringGroovyMethods.findBalanced("test", "\\(", null))
+ }
+
+ @Test
+ void should_ReturnEmptyList_When_NoMatchFound() {
+ List<RegexSupport.BalancedGroup> results = "plain text".findBalanced("\\(", "\\)")
+ assertTrue(results.isEmpty())
+ }
+
+ @Test
+ void should_BuildCompleteTree_When_PerfectlyNested() {
+ String input = "Root: (A + (B * C) + (D(E)))"
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)")
+
+ assertEquals(1, roots.size())
+
+ RegexSupport.BalancedGroup root = roots.get(0)
+ assertEquals("(A + (B * C) + (D(E)))", root.getMatchedString())
+ assertNull(root.getParent())
+ assertEquals(2, root.getChildren().size())
+
+ RegexSupport.BalancedGroup nodeB = root.getChildren().get(0)
+ assertEquals("(B * C)", nodeB.getMatchedString())
+ assertSame(root, nodeB.getParent())
+
+ RegexSupport.BalancedGroup nodeD = root.getChildren().get(1)
+ assertEquals("(D(E))", nodeD.getMatchedString())
+ assertSame(root, nodeD.getParent())
+
+ RegexSupport.BalancedGroup nodeE = nodeD.getChildren().get(0)
+ assertEquals("(E)", nodeE.getMatchedString())
+ assertSame(nodeD, nodeE.getParent())
+ }
+
+ @Test
+ void should_ReturnImmutableEmptyListForChildren_When_NodeIsLeaf() {
+ String input = "Leaf: ()"
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)")
+
+ RegexSupport.BalancedGroup leaf = roots.get(0)
+
+ assertTrue(leaf.getChildren().isEmpty())
+ assertThrows(UnsupportedOperationException.class, () -> leaf.getChildren().add(null))
+ }
+
+ @Test
+ void should_RescueValidChildren_When_DanglingOpenExists() {
+ String input = "(A (B) (C(D)"
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)")
+
+ assertEquals(2, roots.size())
+ assertEquals("(B)", roots.get(0).getMatchedString())
+ assertEquals("(D)", roots.get(1).getMatchedString())
+
+ assertNull(roots.get(0).getParent())
+ }
+
+ @Test
+ void should_IgnoreDanglingClose_When_NoOpenExists() {
+ String input = "A ) B (C) D )"
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)")
+
+ assertEquals(1, roots.size())
+ assertEquals("(C)", roots.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_StripEdges_When_IncludeEdgesIsFalse() {
+ String input = "func(arg1, arg2)"
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults().withIncludeEdges(false)
+
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)", options)
+
+ assertEquals(1, roots.size())
+ assertEquals("arg1, arg2", roots.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_HandleEmptyStringCorrectly_When_IncludeEdgesIsFalseAndContentIsEmpty() {
+ String input = "Empty: ()"
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults().withIncludeEdges(false)
+
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)", options)
+
+ assertEquals(1, roots.size())
+ assertEquals("", roots.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_IgnoreConfiguredPatterns_When_IgnoreRegexIsSet() {
+ String code = "if(true) { String s = \"}\"; }"
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults()
+ .withIgnoreRegex("\"(?:\\\\.|[^\"\\\\])*\"")
+
+ List<RegexSupport.BalancedGroup> roots = code.findBalanced("\\{", "\\}", options)
+
+ assertEquals(1, roots.size())
+ assertEquals("{ String s = \"}\"; }", roots.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_HandleBlankIgnoreRegex_Gracefully() {
+ String input = "(Test)"
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults().withIgnoreRegex(" ")
+ List<RegexSupport.BalancedGroup> roots = input.findBalanced("\\(", "\\)", options)
+ assertEquals("(Test)", roots.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_UseOptionsFromMethodOverload_When_OptionsNotProvided() {
+ List<RegexSupport.BalancedGroup> roots = "(A)".findBalanced("\\(", "\\)", null)
+ assertEquals("(A)", roots.get(0).getMatchedString())
+
+ List<RegexSupport.BalancedGroup> rootsOverload = "[B]".findBalanced("\\[", "\\]")
+ assertEquals("[B]", rootsOverload.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_ExtractNestedHtmlDivs_When_TagsContainAttributes() {
+ 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<RegexSupport.BalancedGroup> roots = html.findBalanced("<div\\b[^>]*>", "</div>")
+
+ assertEquals(2, roots.size(), "Should find two top-level div roots")
+
+ RegexSupport.BalancedGroup appDiv = roots.get(0)
+ assertTrue(appDiv.getMatchedString().startsWith("<div id=\"app\""))
+ assertEquals(2, appDiv.getChildren().size())
+
+ assertEquals("<div class=\"header\" style=\"color: red;\">Header</div>",
+ appDiv.getChildren().get(0).getMatchedString())
+
+ RegexSupport.BalancedGroup bodyDiv = appDiv.getChildren().get(1)
+ assertEquals(1, bodyDiv.getChildren().size())
+ assertEquals("<div class=\"content\">Content</div>",
+ bodyDiv.getChildren().get(0).getMatchedString())
+ }
+
+ @Test
+ void should_ExtractInnerHTML_When_IncludeEdgesIsFalse() {
+ String html = "<div class=\"wrapper\">\n <p>Hello World</p>\n</div>"
+
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults()
+ .withIncludeEdges(false)
+
+ List<RegexSupport.BalancedGroup> roots = html.findBalanced("<div\\b[^>]*>", "</div>", options)
+
+ assertEquals(1, roots.size())
+ assertEquals("\n <p>Hello World</p>\n", roots.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_IgnoreHtmlComments_When_IgnoreRegexIsConfigured() {
+ String html = """
+ <div id="main">
+ <div class="real">Valid Content</div>
+ </div>
+ """
+
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults()
+ .withIgnoreRegex("")
+
+ List<RegexSupport.BalancedGroup> roots = html.findBalanced("<div[^>]*>", "</div>", options)
+
+ assertEquals(1, roots.size(), "AST should contain exactly 1 root block")
+
+ RegexSupport.BalancedGroup mainDiv = roots.get(0)
+ assertTrue(mainDiv.getMatchedString().contains("Valid Content"))
+
+ List<RegexSupport.BalancedGroup> children = mainDiv.getChildren()
+ assertEquals(1, children.size())
+ assertEquals("<div class=\"real\">Valid Content</div>", children.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_IgnoreScriptBlocks_When_JavascriptContainsFakeTags() {
+ String html = """
+ <div id="react-root">
+ <script type="text/javascript">
+ const fakeHtml = "</div>";
+ </script>
+ <div class="app">App Ready</div>
+ </div>
+ """
+
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults()
+ .withIgnoreRegex("(?s)<script\\b[^>]*>.*?</script>")
+
+ List<RegexSupport.BalancedGroup> roots = html.findBalanced("<div\\b[^>]*>", "</div>", options)
+
+ assertEquals(1, roots.size())
+ assertEquals(1, roots.get(0).getChildren().size())
+ assertEquals("<div class=\"app\">App Ready</div>",
+ roots.get(0).getChildren().get(0).getMatchedString())
+ }
+
+ @Test
+ void should_BypassSelfClosingTags_When_AddedToIgnoreRegex() {
+ String html = """
+ <div id="parent">
+ <div class="placeholder" /> <div class="child">I am child</div>
+ </div>
+ """
+
+ RegexSupport.MatchOptions options = RegexSupport.MatchOptions.defaults()
+ .withIgnoreRegex("<div\\b[^>]*/>")
+
+ List<RegexSupport.BalancedGroup> roots = html.findBalanced("<div\\b[^>]*>", "</div>", options)
+
+ assertEquals(1, roots.size())
+
+ List<RegexSupport.BalancedGroup> children = roots.get(0).getChildren()
+ assertEquals(1, children.size())
+ assertEquals("<div class=\"child\">I am child</div>", children.get(0).getMatchedString())
+ }
+
+ @Test
+ void should_RescueValidInnerHtml_When_OuterHtmlIsMalformed() {
+ String html = """
+ <div class="broken-wrapper">
+ <div class="lost-tag"> <div class="target">I am safe!</div>
+ """
+
+ List<RegexSupport.BalancedGroup> roots = html.findBalanced("<div\\b[^>]*>", "</div>")
+
+ assertEquals(1, roots.size())
+ assertEquals("<div class=\"target\">I am safe!</div>", roots.get(0).getMatchedString())
+ }
+}