Skip to content

Commit 1133d2a

Browse files
committed
fix: B12 quantifier-prefix support in VARIABLE_CAPTURE_BACKREF
2 parents 39122bc + de8a2d6 commit 1133d2a

3 files changed

Lines changed: 148 additions & 5 deletions

File tree

‎reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/analysis/FallbackPatternDetector.java‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,9 @@ && hasLookaheadInAlternation(ast)) {
187187
// Generator now caps the initial groupEnd to info.groupMaxCount when the group has a bounded
188188
// quantifier, so this fallback condition is no longer needed.
189189

190-
// B12 [PARTIALLY-FIXED]: emitPrefixMatch handles Literal, CharClass, Anchor, and non-capturing
191-
// GroupNode (via isPrefixNodeHandleable recursion). Prefix patterns whose top-level node is a
192-
// QuantifierNode or another unsupported type still fall back.
190+
// B12 [PARTIALLY-FIXED]: emitPrefixMatch handles Literal, CharClass, Anchor, non-capturing
191+
// GroupNode (via isPrefixNodeHandleable recursion), and unbounded/exact QuantifierNodes (e.g.
192+
// a*, a+, [0-9]*, x{3}). Bounded-range quantifiers {n,m} still fall back.
193193
if (strategy == PatternAnalyzer.MatchingStrategy.VARIABLE_CAPTURE_BACKREF
194194
&& hasNonAnchorPrefixBeforeBackrefGroup(ast)) {
195195
return "variable-capture backref with unsupported prefix node type: "
@@ -1002,6 +1002,14 @@ private static boolean isPrefixNodeHandleable(RegexNode node) {
10021002
}
10031003
return true;
10041004
}
1005+
if (node instanceof QuantifierNode q) {
1006+
// Handle unbounded (max == -1: *, +, {n,}) and exact ({n}) quantifiers.
1007+
// Bounded ranges {n,m} with m > n are not yet implemented in emitPrefixNode.
1008+
if (q.max == -1 || q.min == q.max) {
1009+
return isPrefixNodeHandleable(q.child);
1010+
}
1011+
return false;
1012+
}
10051013
return false;
10061014
}
10071015

@@ -1033,7 +1041,8 @@ private static boolean hasNonAnchorPrefixBeforeBackrefGroup(RegexNode ast) {
10331041
GroupNode g = (GroupNode) q.child;
10341042
if (g.capturing && backrefNums.contains(g.groupNumber)) return false;
10351043
}
1036-
return true; // quantified node in prefix: not handled
1044+
if (isPrefixNodeHandleable(child)) continue; // handled by emitPrefixNode
1045+
return true; // bounded-range quantified prefix: not handled
10371046
}
10381047
if (child instanceof LiteralNode || child instanceof CharClassNode) {
10391048
continue; // handled by emitPrefixMatch

‎reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/VariableCaptureBackrefBytecodeGenerator.java‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.datadoghq.reggie.codegen.ast.ConcatNode;
2525
import com.datadoghq.reggie.codegen.ast.GroupNode;
2626
import com.datadoghq.reggie.codegen.ast.LiteralNode;
27+
import com.datadoghq.reggie.codegen.ast.QuantifierNode;
2728
import com.datadoghq.reggie.codegen.ast.RegexNode;
2829
import com.datadoghq.reggie.codegen.automaton.CharSet;
2930
import org.objectweb.asm.ClassWriter;
@@ -712,11 +713,33 @@ private void generateFindMatchFromMethod(ClassWriter cw) {
712713
private int getPrefixLength() {
713714
int n = 0;
714715
for (RegexNode node : info.prefix) {
715-
if (!(node instanceof AnchorNode)) n++;
716+
n += prefixNodeMinLength(node);
716717
}
717718
return n;
718719
}
719720

721+
/** Returns the minimum number of characters consumed by a single prefix node. */
722+
private static int prefixNodeMinLength(RegexNode node) {
723+
if (node instanceof AnchorNode) return 0;
724+
if (node instanceof LiteralNode || node instanceof CharClassNode) return 1;
725+
if (node instanceof QuantifierNode) {
726+
QuantifierNode q = (QuantifierNode) node;
727+
return q.min * prefixNodeMinLength(q.child);
728+
}
729+
if (node instanceof GroupNode) {
730+
GroupNode g = (GroupNode) node;
731+
return g.capturing ? 0 : prefixNodeMinLength(g.child);
732+
}
733+
if (node instanceof ConcatNode) {
734+
int total = 0;
735+
for (RegexNode child : ((ConcatNode) node).children) {
736+
total += prefixNodeMinLength(child);
737+
}
738+
return total;
739+
}
740+
return 0;
741+
}
742+
720743
private void emitCharSetCheck(
721744
MethodVisitor mv, int charVar, CharSet cs, boolean negated, Label failLabel) {
722745
if (!negated) {
@@ -801,6 +824,23 @@ private void emitPrefixNode(
801824
for (RegexNode child : ((ConcatNode) node).children) {
802825
emitPrefixNode(mv, child, groupStartVar, lenVar, failLabel, alloc);
803826
}
827+
} else if (node instanceof QuantifierNode) {
828+
QuantifierNode q = (QuantifierNode) node;
829+
// Emit min mandatory repetitions — if too few chars, jump to failLabel (match fails).
830+
for (int i = 0; i < q.min; i++) {
831+
emitPrefixNode(mv, q.child, groupStartVar, lenVar, failLabel, alloc);
832+
}
833+
// For unbounded quantifiers (max == -1): greedy loop for optional repetitions.
834+
// Use loopEnd as the child's failLabel so the loop exits without failing the match.
835+
if (q.max == -1) {
836+
Label loopStart = new Label();
837+
Label loopEnd = new Label();
838+
mv.visitLabel(loopStart);
839+
emitPrefixNode(mv, q.child, groupStartVar, lenVar, loopEnd, alloc);
840+
mv.visitJumpInsn(GOTO, loopStart);
841+
mv.visitLabel(loopEnd);
842+
}
843+
// For exact quantifiers (q.min == q.max): mandatory repetitions already emitted above.
804844
}
805845
}
806846

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2026-Present Datadog, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.datadoghq.reggie.runtime;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertFalse;
20+
21+
import com.datadoghq.reggie.Reggie;
22+
import com.datadoghq.reggie.ReggieOptions;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
25+
import java.util.stream.Stream;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.Arguments;
28+
import org.junit.jupiter.params.provider.MethodSource;
29+
30+
/**
31+
* Regression coverage for B12: quantifier nodes in the prefix before a capturing backref group.
32+
* After the fix these patterns route natively via VARIABLE_CAPTURE_BACKREF.
33+
*
34+
* <p>Patterns require variable-length group content (e.g. {@code (b+)}, {@code ([0-9]+)}) so they
35+
* are detected as VARIABLE_CAPTURE_BACKREF; the prefix quantifier (e.g. {@code a*}, {@code x{3}})
36+
* is what previously caused the fallback.
37+
*/
38+
class B12QuantifierPrefixTest {
39+
40+
private static final ReggieOptions WITH_FALLBACK =
41+
ReggieOptions.builder().allowJdkFallback().build();
42+
43+
static Stream<Arguments> quantifierPrefixPatterns() {
44+
return Stream.of(
45+
Arguments.of("a*(b+)\\1", "bb"),
46+
Arguments.of("a*(b+)\\1", "abb"),
47+
Arguments.of("a*(b+)\\1", "aabb"),
48+
Arguments.of("a*(b+)\\1", "aac"),
49+
Arguments.of("a+(b+)\\1", "abb"),
50+
Arguments.of("a+(b+)\\1", "aabb"),
51+
Arguments.of("a+(b+)\\1", "bb"),
52+
Arguments.of("[0-9]*([a-z]+)\\1", "aa"),
53+
Arguments.of("[0-9]*([a-z]+)\\1", "1aa"),
54+
Arguments.of("[0-9]*([a-z]+)\\1", "123aa"),
55+
Arguments.of("[0-9]*([a-z]+)\\1", "ab"),
56+
Arguments.of("x{3}(a+)\\1", "xxxaa"),
57+
Arguments.of("x{3}(a+)\\1", "xxaa"),
58+
Arguments.of("x{3}(a+)\\1", "xxxxaa"));
59+
}
60+
61+
@ParameterizedTest(name = "[{index}] pat={0} in={1}")
62+
@MethodSource("quantifierPrefixPatterns")
63+
void agreesWithJdk(String pat, String in) {
64+
ReggieMatcher reggie = Reggie.compile(pat, WITH_FALLBACK);
65+
Pattern jdk = Pattern.compile(pat);
66+
String ctx = "pat=" + pat + " in=" + repr(in);
67+
assertEquals(jdk.matcher(in).matches(), reggie.matches(in), "matches() " + ctx);
68+
assertEquals(jdk.matcher(in).find(), reggie.find(in), "find() " + ctx);
69+
Matcher jm = jdk.matcher(in);
70+
boolean jFound = jm.find();
71+
MatchResult rf = reggie.findMatch(in);
72+
assertEquals(jFound, rf != null, "findMatch() null " + ctx);
73+
if (jFound && rf != null) {
74+
assertEquals(jm.start(), rf.start(), "findMatch() start " + ctx);
75+
assertEquals(jm.end(), rf.end(), "findMatch() end " + ctx);
76+
if (jm.groupCount() >= 1 && jm.start(1) != -1 && rf.start(1) != -1) {
77+
assertEquals(jm.start(1), rf.start(1), "g1 start " + ctx);
78+
assertEquals(jm.end(1), rf.end(1), "g1 end " + ctx);
79+
}
80+
}
81+
}
82+
83+
@ParameterizedTest(name = "[{index}] pat={0} in={1}")
84+
@MethodSource("quantifierPrefixPatterns")
85+
void routesToNative(String pat, String in) {
86+
assertFalse(
87+
Reggie.compile(pat) instanceof JavaRegexFallbackMatcher,
88+
"expected native matcher for: " + pat);
89+
}
90+
91+
private static String repr(String s) {
92+
return s.isEmpty() ? "(empty)" : "\"" + s.replace("\n", "\\n") + "\"";
93+
}
94+
}

0 commit comments

Comments
 (0)