From 3f2507659b425b88834c9fd42a3f8c7725cba4b8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Jul 2025 08:38:41 -0400 Subject: [PATCH] Remove capture groups from negative lookarounds Any captures performed inside of negative lookarounds do not persist to outside of the lookaround. As such, as long as there are no backreferences inside of the lookaround that would read on those captures, we can eliminate the capturing. --- .../Text/RegularExpressions/RegexNode.cs | 63 +++++++++++++++++++ .../tests/UnitTests/RegexReductionTests.cs | 8 +++ 2 files changed, 71 insertions(+) diff --git a/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexNode.cs b/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexNode.cs index 10ec814bea2649..28d47963777cfd 100644 --- a/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexNode.cs +++ b/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexNode.cs @@ -2021,6 +2021,43 @@ private RegexNode ReduceLookaround() Debug.Assert(Kind is RegexNodeKind.PositiveLookaround or RegexNodeKind.NegativeLookaround); Debug.Assert(ChildCount() == 1); + // Captures inside of negative lookarounds are undone after the lookaround. Thus, if there's nothing + // inside of the negative lookaround that needs that capture group (namely a backreference), we can + // remove the capture. + if (Kind is RegexNodeKind.NegativeLookaround && ContainsBackreference(Child(0)) is false) + { + if (RemoveCaptures(this, 0)) + { + // If we removed captures, we may have changed the structure of the tree in a way that exposed more + // optimization possibility, so re-reduce the children. + ReplaceChild(0, Child(0)); + } + + static bool RemoveCaptures(RegexNode parent, int nodeIndex) + { + RegexNode node = parent.Child(nodeIndex); + + if (node.Kind is RegexNodeKind.Capture) + { + parent.ReplaceChild(nodeIndex, node.Child(0)); + RemoveCaptures(parent, nodeIndex); + return true; + } + + bool changesMade = false; + if (StackHelper.TryEnsureSufficientExecutionStack()) + { + int childCount = node.ChildCount(); + for (int i = 0; i < childCount; i++) + { + changesMade |= RemoveCaptures(node, i); + } + } + + return changesMade; + } + } + // A lookaround is a zero-width atomic assertion. // As it's atomic, nothing will backtrack into it, and we can // eliminate any ending backtracking from it. @@ -2043,6 +2080,32 @@ private RegexNode ReduceLookaround() return this; } + /// Gets whether the node contains a backreference anywhere in its tree. + private static bool? ContainsBackreference(RegexNode node) + { + if (node.Kind is RegexNodeKind.Backreference or RegexNodeKind.BackreferenceConditional) + { + return true; + } + + if (!StackHelper.TryEnsureSufficientExecutionStack()) + { + // If we can't recur further, just stop optimizing. + return null; + } + + int childCount = node.ChildCount(); + for (int i = 0; i < childCount; i++) + { + if (ContainsBackreference(node.Child(i)) is true) + { + return true; + } + } + + return false; + } + /// Optimizations for backreference conditionals. private RegexNode ReduceBackreferenceConditional() { diff --git a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/RegexReductionTests.cs b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/RegexReductionTests.cs index 31967fb8d965b8..ef4c64eb062022 100644 --- a/src/libraries/System.Text.RegularExpressions/tests/UnitTests/RegexReductionTests.cs +++ b/src/libraries/System.Text.RegularExpressions/tests/UnitTests/RegexReductionTests.cs @@ -256,6 +256,10 @@ public class RegexReductionTests [InlineData("(?>(?>(?>(?>))))", "")] [InlineData("(?>(?>(?>(?>(?!)))))", "(?!)")] [InlineData("(?=(?>))", "")] + // Lookaround reduction + [InlineData("(?!(abc))", "(?!abc)")] + [InlineData("(?!a(b*)c)", "(?!ab*c)")] + [InlineData("(?!a((((b))))c)", "(?!abc)")] // Alternation reduction [InlineData("a|b", "[ab]")] [InlineData("a|b|c|d|e|g|h|z", "[a-eghz]")] @@ -518,6 +522,10 @@ public void PatternsReduceIdentically(string actual, string expected) [InlineData("(abc?)*?d", "(?>(ab(?>c?))*)d")] [InlineData("(aba)+d", "(?>(aba)+)d")] [InlineData("(abc*)*d", "(?>(ab(?>c*))*)d")] + // Lookaround reduction + [InlineData("(?=(abc))", "(?=abc)")] + [InlineData("(?=a(b*)c)", "(?=ab*c)")] + [InlineData("(?=a((((b))))c)", "(?=abc)")] // Loops inside alternation constructs [InlineData("(abc*|def)chi", "(ab(?>c*)|def)chi")] [InlineData("(abc|def*)fhi", "(abc|de(?>f*))fhi")]