Skip to content

Commit b74c879

Browse files
committed
fix(regex): backtrack language tag extension splits
test262 intl402/Intl VALID 63/66 -> 66/66 (+3, 95.5% -> 100.0%): getCanonicalLocales/transformed-ext-canonical, canonicalized-tags, and transformed-ext-valid now pass once repeated extension alternations can retarget an end-anchor split through an optional private-use suffix. zig-regex regression 75 -> 77 (+2 focused cases, 100.0% green): added the reduced da-u-attr extension case and the full Test262 languageTagRE shape. Verified zig-regex regression 77/77, zig-js Intl locale canonicalization unit green, regex literals unit green, four focused diag files PASS, and test262 worker test/intl402/Intl 66/66.
1 parent cb5f11e commit b74c879

2 files changed

Lines changed: 185 additions & 56 deletions

File tree

‎src/backtrack.zig‎

Lines changed: 163 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,26 @@ pub const BacktrackEngine = struct {
301301

302302
@memcpy(self.captures, saved_captures);
303303
}
304+
if (!right_has_choices and self.needsAnchoredRepeatedAlternationRecovery(concat.left, concat.right)) {
305+
var target = self.input.len;
306+
while (target >= pos) {
307+
@memcpy(self.captures, clean_captures);
308+
const screened = self.matchNode(concat.right, target) orelse {
309+
if (target == pos) break;
310+
target -= 1;
311+
continue;
312+
};
313+
@memcpy(self.captures, clean_captures);
314+
if (self.matchNodeConstrained(concat.left, pos, target)) {
315+
@memcpy(self.captures, clean_captures);
316+
_ = self.matchNodeConstrained(concat.left, pos, target);
317+
if (self.matchNode(concat.right, target)) |result| return result;
318+
}
319+
_ = screened;
320+
if (target == pos) break;
321+
target -= 1;
322+
}
323+
}
304324
@memcpy(self.captures, clean_captures);
305325
return null;
306326
} else {
@@ -373,10 +393,36 @@ pub const BacktrackEngine = struct {
373393
if (right_end == target_end) return true;
374394
}
375395
}
396+
if (self.canMatchEmpty(c.right)) {
397+
@memcpy(self.captures, base_captures);
398+
if (self.matchNodeConstrained(c.left, pos, target_end) and
399+
self.matchNodeConstrained(c.right, target_end, target_end)) return true;
400+
}
376401
@memcpy(self.captures, base_captures);
377402
return false;
378403
}
379404
},
405+
.star, .plus, .optional, .repeat => {
406+
return self.matchQuantifierConstrained(node, pos, target_end);
407+
},
408+
.alternation => {
409+
if (self.matchNodeConstrained(node.data.alternation.left, pos, target_end)) return true;
410+
return self.matchNodeConstrained(node.data.alternation.right, pos, target_end);
411+
},
412+
.backref => {
413+
return if (self.matchBackreference(node.data.backref, pos)) |end| end == target_end else false;
414+
},
415+
.lookahead => {
416+
return if (self.matchLookahead(node.data.lookahead, pos)) |end| end == target_end else false;
417+
},
418+
.lookbehind => {
419+
return if (self.matchLookbehind(node.data.lookbehind, pos)) |end| end == target_end else false;
420+
},
421+
}
422+
}
423+
424+
fn matchQuantifierConstrained(self: *BacktrackEngine, node: *ast.Node, pos: usize, target_end: usize) bool {
425+
switch (node.node_type) {
380426
.star, .plus, .optional, .repeat => {
381427
const child = switch (node.node_type) {
382428
.star => node.data.star.child,
@@ -392,63 +438,58 @@ pub const BacktrackEngine = struct {
392438
else => unreachable,
393439
};
394440

395-
// A required zero-width iteration, such as `(?=(x)){1,1}`,
396-
// still runs the child and can update captures. Optional
397-
// zero-width iterations are skipped, matching RepeatMatcher's
398-
// min=0 empty-iteration failure rule.
399-
if (pos == target_end) {
400-
var count: usize = 0;
401-
while (count < min) : (count += 1) {
402-
if (!self.matchNodeConstrained(child, pos, target_end)) return false;
403-
}
404-
return true;
405-
}
406-
407-
// For optional, only one match allowed
408441
if (node.node_type == .optional) {
409-
return if (self.matchNode(child, pos)) |end| end == target_end else false;
442+
if (pos == target_end) return true;
443+
return self.matchNodeConstrained(child, pos, target_end);
410444
}
411445

412-
// Try matching child repeatedly until we reach target_end
413-
var current = pos;
414-
var count: usize = 0;
415-
while (current < target_end) {
416-
self.clearCapturesIn(child);
417-
if (self.matchNode(child, current)) |next| {
418-
if (next <= current) return false;
419-
current = next;
420-
count += 1;
421-
if (current == target_end) {
422-
// Validate count constraints
423-
if (node.node_type == .plus and count < 1) return false;
424-
if (node.node_type == .repeat) {
425-
if (count < node.data.repeat.bounds.min) return false;
426-
if (node.data.repeat.bounds.max) |max| {
427-
if (count > max) return false;
428-
}
429-
}
430-
return true;
431-
}
432-
} else {
433-
return false;
434-
}
435-
}
436-
return false;
437-
},
438-
.alternation => {
439-
if (self.matchNodeConstrained(node.data.alternation.left, pos, target_end)) return true;
440-
return self.matchNodeConstrained(node.data.alternation.right, pos, target_end);
441-
},
442-
.backref => {
443-
return if (self.matchBackreference(node.data.backref, pos)) |end| end == target_end else false;
444-
},
445-
.lookahead => {
446-
return if (self.matchLookahead(node.data.lookahead, pos)) |end| end == target_end else false;
447-
},
448-
.lookbehind => {
449-
return if (self.matchLookbehind(node.data.lookbehind, pos)) |end| end == target_end else false;
446+
const max: ?usize = switch (node.node_type) {
447+
.star, .plus => null,
448+
.repeat => node.data.repeat.bounds.max,
449+
else => unreachable,
450+
};
451+
return self.matchRepeatedChildConstrained(child, pos, target_end, 0, min, max);
450452
},
453+
else => return false,
454+
}
455+
}
456+
457+
fn matchRepeatedChildConstrained(self: *BacktrackEngine, child: *ast.Node, pos: usize, target_end: usize, count: usize, min: usize, max: ?usize) bool {
458+
if (count >= min and pos == target_end) return true;
459+
if (pos == target_end) {
460+
var n = count;
461+
while (n < min) : (n += 1) {
462+
if (!self.matchNodeConstrained(child, pos, target_end)) return false;
463+
}
464+
return true;
451465
}
466+
if (pos >= target_end) return false;
467+
if (max) |m| if (count >= m) return false;
468+
469+
const saved = self.allocator.alloc(CaptureGroup, self.captures.len) catch return false;
470+
defer self.allocator.free(saved);
471+
@memcpy(saved, self.captures);
472+
473+
var ends = std.ArrayList(usize).initCapacity(self.allocator, 0) catch return false;
474+
defer ends.deinit(self.allocator);
475+
self.collectAllMatches(child, pos, &ends) catch return false;
476+
477+
for (ends.items) |end| {
478+
if (end <= pos or end > target_end) continue;
479+
@memcpy(self.captures, saved);
480+
if (!self.matchNodeConstrained(child, pos, end)) continue;
481+
if (self.matchRepeatedChildConstrained(child, end, target_end, count + 1, min, max)) return true;
482+
}
483+
484+
if (self.hasAlternation(child) and self.hasCapturingGroup(child)) {
485+
@memcpy(self.captures, saved);
486+
if (self.matchNodeConstrained(child, pos, target_end)) {
487+
if (self.matchRepeatedChildConstrained(child, target_end, target_end, count + 1, min, max)) return true;
488+
}
489+
}
490+
491+
@memcpy(self.captures, saved);
492+
return false;
452493
}
453494

454495
fn hasQuantifiers(self: *BacktrackEngine, node: *ast.Node) bool {
@@ -478,6 +519,49 @@ pub const BacktrackEngine = struct {
478519
};
479520
}
480521

522+
fn hasRepeatedAlternation(self: *BacktrackEngine, node: *ast.Node) bool {
523+
return switch (node.node_type) {
524+
.star => self.hasAlternation(node.data.star.child) or self.hasRepeatedAlternation(node.data.star.child),
525+
.plus => self.hasAlternation(node.data.plus.child) or self.hasRepeatedAlternation(node.data.plus.child),
526+
.repeat => self.hasAlternation(node.data.repeat.child) or self.hasRepeatedAlternation(node.data.repeat.child),
527+
.optional => self.hasRepeatedAlternation(node.data.optional.child),
528+
.concat => self.hasRepeatedAlternation(node.data.concat.left) or self.hasRepeatedAlternation(node.data.concat.right),
529+
.alternation => self.hasRepeatedAlternation(node.data.alternation.left) or self.hasRepeatedAlternation(node.data.alternation.right),
530+
.group => self.hasRepeatedAlternation(node.data.group.child),
531+
.lookahead => self.hasRepeatedAlternation(node.data.lookahead.child),
532+
.lookbehind => self.hasRepeatedAlternation(node.data.lookbehind.child),
533+
else => false,
534+
};
535+
}
536+
537+
fn needsAnchoredRepeatedAlternationRecovery(self: *BacktrackEngine, left: *ast.Node, right: *ast.Node) bool {
538+
return self.hasRepeatedAlternation(left) and self.hasCapturingGroup(left) and self.isEndAnchorOnly(right);
539+
}
540+
541+
fn hasCapturingGroup(self: *BacktrackEngine, node: *ast.Node) bool {
542+
return switch (node.node_type) {
543+
.group => node.data.group.capture_index != null or self.hasCapturingGroup(node.data.group.child),
544+
.concat => self.hasCapturingGroup(node.data.concat.left) or self.hasCapturingGroup(node.data.concat.right),
545+
.alternation => self.hasCapturingGroup(node.data.alternation.left) or self.hasCapturingGroup(node.data.alternation.right),
546+
.star => self.hasCapturingGroup(node.data.star.child),
547+
.plus => self.hasCapturingGroup(node.data.plus.child),
548+
.optional => self.hasCapturingGroup(node.data.optional.child),
549+
.repeat => self.hasCapturingGroup(node.data.repeat.child),
550+
.lookahead => self.hasCapturingGroup(node.data.lookahead.child),
551+
.lookbehind => self.hasCapturingGroup(node.data.lookbehind.child),
552+
else => false,
553+
};
554+
}
555+
556+
fn isEndAnchorOnly(self: *BacktrackEngine, node: *ast.Node) bool {
557+
return switch (node.node_type) {
558+
.anchor => node.data.anchor == .end_line or node.data.anchor == .end_text,
559+
.group => self.isEndAnchorOnly(node.data.group.child),
560+
.concat => self.canMatchEmpty(node.data.concat.left) and self.isEndAnchorOnly(node.data.concat.right),
561+
else => false,
562+
};
563+
}
564+
481565
/// Whether a subtree contains a group (whose captures a constrained re-match
482566
/// would need to set). Conservative: any group counts. When false, the
483567
/// capture-fixup re-match in matchConcat/collectAllMatches is pure waste and
@@ -584,6 +668,11 @@ pub const BacktrackEngine = struct {
584668
const right_has_choices = self.hasQuantifiers(c.right) or self.hasAlternation(c.right);
585669

586670
if (left_has_choices or right_has_choices) {
671+
const clean_captures = self.allocator.alloc(CaptureGroup, self.captures.len) catch return;
672+
defer self.allocator.free(clean_captures);
673+
@memcpy(clean_captures, self.captures);
674+
const before_len = positions.items.len;
675+
587676
// Collect all possible left-side endings
588677
var left_positions = std.ArrayList(usize).initCapacity(self.allocator, 0) catch return;
589678
defer left_positions.deinit(self.allocator);
@@ -624,6 +713,24 @@ pub const BacktrackEngine = struct {
624713

625714
@memcpy(self.captures, saved);
626715
}
716+
if (positions.items.len == before_len and !right_has_choices and self.needsAnchoredRepeatedAlternationRecovery(c.left, c.right)) {
717+
var target = self.input.len;
718+
while (target >= pos) {
719+
@memcpy(self.captures, clean_captures);
720+
const screened = self.matchNode(c.right, target) orelse {
721+
if (target == pos) break;
722+
target -= 1;
723+
continue;
724+
};
725+
@memcpy(self.captures, clean_captures);
726+
if (self.matchNodeConstrained(c.left, pos, target)) {
727+
try positions.append(self.allocator, screened);
728+
}
729+
if (target == pos) break;
730+
target -= 1;
731+
}
732+
}
733+
@memcpy(self.captures, clean_captures);
627734
} else {
628735
// No quantifiers in either side, single match
629736
if (self.matchNode(node, pos)) |end| {
@@ -658,8 +765,8 @@ pub const BacktrackEngine = struct {
658765
}
659766
}
660767

661-
fn collectGreedyStarMatches(self: *BacktrackEngine, child: *ast.Node, pos: usize, positions: *std.ArrayList(usize)) !void {
662-
// Collect all matches from longest to shortest
768+
fn collectGreedyStarMatches(self: *BacktrackEngine, child: *ast.Node, pos: usize, positions: *std.ArrayList(usize)) anyerror!void {
769+
// Collect all matches from longest to shortest.
663770
var all_positions = std.ArrayList(usize).initCapacity(self.allocator, 0) catch return;
664771
defer all_positions.deinit(self.allocator);
665772

@@ -673,15 +780,15 @@ pub const BacktrackEngine = struct {
673780
try all_positions.append(self.allocator, current_pos);
674781
}
675782

676-
// Return in reverse order (greedy: longest first)
783+
// Return in reverse order (greedy: longest first).
677784
var i: usize = all_positions.items.len;
678785
while (i > 0) {
679786
i -= 1;
680787
try positions.append(self.allocator, all_positions.items[i]);
681788
}
682789
}
683790

684-
fn collectLazyStarMatches(self: *BacktrackEngine, child: *ast.Node, pos: usize, positions: *std.ArrayList(usize)) !void {
791+
fn collectLazyStarMatches(self: *BacktrackEngine, child: *ast.Node, pos: usize, positions: *std.ArrayList(usize)) anyerror!void {
685792
// Collect all matches from shortest to longest
686793
try positions.append(self.allocator, pos); // zero matches first
687794

‎tests/regression.zig‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,28 @@ test "regression: case-insensitive prefilter (folded first bytes) finds both cas
14981498
try std.testing.expect(!try re.isMatch("public function"));
14991499
}
15001500

1501+
test "regression: capture-heavy alternation under repeated extension group" {
1502+
const allocator = std.testing.allocator;
1503+
var re = try Regex.compile(allocator, "^([a-z]{2,3})(-(u((-at)+|((-([a-z0-9]{3,8}))+))))*$");
1504+
defer re.deinit();
1505+
1506+
try std.testing.expect(try re.isMatch("da-u-attr"));
1507+
const match = (try re.find("da-u-attr")) orelse return error.TestExpectedMatch;
1508+
var mut_match = match;
1509+
defer mut_match.deinit(allocator);
1510+
try std.testing.expectEqualStrings("da-u-attr", match.slice);
1511+
}
1512+
1513+
test "regression: test262 language tag extensions with optional privateuse suffix" {
1514+
const allocator = std.testing.allocator;
1515+
var re = try Regex.compileWithFlags(allocator, "^((([a-z]{2,3}|[a-z]{5,8})(-([a-z]{4}))?(-([a-z]{2}|[0-9]{3}))?(-([a-z0-9]{5,8}|(?:[0-9][a-z0-9]{3})))*(-((u((-([a-z0-9][a-z](-[a-z0-9]{3,8})*))+|((-([a-z0-9]{3,8}))+(-([a-z0-9][a-z](-[a-z0-9]{3,8})*))*)))|(t((-(([a-z]{2,3}|[a-z]{5,8})(-([a-z]{4}))?(-([a-z]{2}|[0-9]{3}))?(-([a-z0-9]{5,8}|(?:[0-9][a-z0-9]{3})))*)(-([a-z][0-9](-[a-z0-9]{3,8})+))*)|(-([a-z][0-9](-[a-z0-9]{3,8})+))+))|(([0-9]|[a-sv-wy-z])(-[a-z0-9]{2,8})+)))*(-(x(-[a-z0-9]{1,8})+))?))$", .{ .case_insensitive = true });
1516+
defer re.deinit();
1517+
1518+
try std.testing.expect(try re.isMatch("da-u-attr"));
1519+
try std.testing.expect(try re.isMatch("sl-t-sl-1994-biske-rozaj"));
1520+
try std.testing.expect(try re.isMatch("en-t-en-emodeng"));
1521+
}
1522+
15011523
// --- literal-alternation two-byte (Teddy-lite) prefilter ---
15021524
//
15031525
// Literal alternations use a vectorized two-byte-prefix filter to find

0 commit comments

Comments
 (0)