From f107d1acfca72a57d8b08635b96d64dfbd2d86b6 Mon Sep 17 00:00:00 2001 From: Max Chesnikov Date: Tue, 20 Oct 2026 08:51:57 -0700 Subject: [PATCH] Fix forward progress analysis losing its place in the node list `_guaranteesForwardProgressImpl` walks the flattened node list with a cursor, and several of its cases answer without visiting every child: `contains` stops at the first child of a concatenation that progresses, `allSatisfy` at the first alternative that doesn't, and a lookahead or a zero-lower-bound quantification doesn't look at its child at all. When a case stopped early, the cursor was left in the middle of the subtree, and the caller then read a node from inside it as the next sibling. In `(?:.c|)*` the first alternative is the concatenation `.c`. Deciding it only takes `.`, so `c` was read as the second, empty alternative. That made every alternative look like it progresses, so no progress check was emitted and matching the empty alternative looped forever - or grew until an allocation failed. Find the end of the node's subtree up front and restore the cursor to it on the way out, so the invariant holds however much of the subtree a case needs to look at. Fixes #897, fixes #894. --- .../ByteCodeGen+DSLList.swift | 15 ++++++++--- Tests/RegexTests/CompileTests.swift | 18 +++++++++++++ Tests/RegexTests/MatchTests.swift | 26 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Sources/_StringProcessing/ByteCodeGen+DSLList.swift b/Sources/_StringProcessing/ByteCodeGen+DSLList.swift index 4d1290cd0..f11f1f9a7 100644 --- a/Sources/_StringProcessing/ByteCodeGen+DSLList.swift +++ b/Sources/_StringProcessing/ByteCodeGen+DSLList.swift @@ -351,9 +351,20 @@ fileprivate extension Compiler.ByteCodeGen { } } + /// Returns whether the node at `position` is guaranteed to consume input, + /// leaving `position` just past that node's entire subtree, so that a caller + /// walking a list of nodes reads the next sibling. + /// + /// Some of the cases below stop looking at children as soon as the answer is + /// known, and others don't look at them at all, so the end of the subtree is + /// found up front rather than left to the traversal to reach. func _guaranteesForwardProgressImpl(_ list: ArraySlice, position: inout Int) -> Bool { guard position < list.endIndex else { return false } let node = list[position] + var endOfSubtree = position + list.skipNode(&endOfSubtree) + endOfSubtree += 1 + defer { position = endOfSubtree } position += 1 switch node { case .orderedChoice(let count): @@ -369,8 +380,6 @@ fileprivate extension Compiler.ByteCodeGen { case .nonCapturingGroup(let kind): switch kind.ast { case .lookahead, .negativeLookahead, .lookbehind, .negativeLookbehind: - list.skipNode(&position) - position += 1 return false default: return _guaranteesForwardProgressImpl(list, position: &position) @@ -396,8 +405,6 @@ fileprivate extension Compiler.ByteCodeGen { if let atLeast, atLeast > 0 { return _guaranteesForwardProgressImpl(list, position: &position) } else { - list.skipNode(&position) - position += 1 return false } case .limitCaptureNesting, .ignoreCapturesInTypedOutput: diff --git a/Tests/RegexTests/CompileTests.swift b/Tests/RegexTests/CompileTests.swift index 0879a875e..6cc872597 100644 --- a/Tests/RegexTests/CompileTests.swift +++ b/Tests/RegexTests/CompileTests.swift @@ -556,6 +556,24 @@ extension RegexTests { // Inner node is a quantification that guarantees forward progress expectProgram(for: #"(a+)*"#, doesNotContain: [.moveCurrentPosition, .condBranchSamePosition]) expectProgram(for: #"(a{1,})*"#, doesNotContain: [.moveCurrentPosition, .condBranchSamePosition]) + + // An empty alternative next to one that is a concatenation of more than + // one node. Deciding the concatenation takes only its first child, so the + // walk has to skip the rest of it to find the empty alternative. + // https://github.com/swiftlang/swift-experimental-string-processing/issues/897 + expectProgram(for: #"(?:.c|)*"#, contains: [.moveCurrentPosition, .condBranchSamePosition]) + expectProgram(for: #"(?:\sA|)*?"#, contains: [.moveCurrentPosition, .condBranchSamePosition]) + expectProgram(for: #"(?:[ab]c|)*"#, contains: [.moveCurrentPosition, .condBranchSamePosition]) + // https://github.com/swiftlang/swift-experimental-string-processing/issues/894 + expectProgram(for: #"(?:.b|)+"#, contains: [.moveCurrentPosition, .condBranchSamePosition]) + // Every alternative progresses, so no check is needed even though one of + // them is a concatenation. + expectProgram(for: #"(?:.c|b)*"#, doesNotContain: [.moveCurrentPosition, .condBranchSamePosition]) + expectProgram(for: #"(?:.c|b|[de]f)*"#, doesNotContain: [.moveCurrentPosition, .condBranchSamePosition]) + // A concatenation progresses as soon as one of its children does, wherever + // in the concatenation that child is. + expectProgram(for: #"(?:(?=a)\b.)*"#, doesNotContain: [.moveCurrentPosition, .condBranchSamePosition]) + expectProgram(for: #"(?:(?=a)\b(?:))*"#, contains: [.moveCurrentPosition, .condBranchSamePosition]) } func testCanOnlyMatchAtStart() throws { diff --git a/Tests/RegexTests/MatchTests.swift b/Tests/RegexTests/MatchTests.swift index 2d9a6b1c2..cb190a9ff 100644 --- a/Tests/RegexTests/MatchTests.swift +++ b/Tests/RegexTests/MatchTests.swift @@ -2917,6 +2917,32 @@ extension RegexTests { expectCompletion(regex: #"(?:(?=\d)\w*)*"#, in: "a") expectCompletion(regex: #"(?:(?!b)\d*)*"#, in: "a") expectCompletion(regex: #"(?:(?!\d)[0-9]*)*"#, in: "a") + + // An empty alternative next to one that is a concatenation of more than + // one node. + // https://github.com/swiftlang/swift-experimental-string-processing/issues/897 + expectCompletion(regex: #"(?:.c|)*x"#, in: "b") + expectCompletion(regex: #"(?:\sA|)*?x"#, in: "b") + expectCompletion(regex: #"(?:[ab]c|)*x"#, in: "b") + // https://github.com/swiftlang/swift-experimental-string-processing/issues/894 + expectCompletion(regex: #"(?:.b|)+."#, in: "a") + } + + func testEmptyAlternativeInQuantification() { + // https://github.com/swiftlang/swift-experimental-string-processing/issues/897 + firstMatchTests( + #"(?:.c|)*x"#, + ("b", nil), ("x", "x"), ("bcx", "bcx"), ("acbcx", "acbcx")) + firstMatchTests( + #"(?:\sA|)*?x"#, + ("b", nil), ("x", "x"), (" Ax", " Ax")) + firstMatchTests( + #"(?:[ab]c|)*x"#, + ("b", nil), ("acbcx", "acbcx")) + // https://github.com/swiftlang/swift-experimental-string-processing/issues/894 + firstMatchTests( + #"(?:.b|)+."#, + ("a", "a"), ("ab", "a"), ("abb", "abb")) } func testQuantifyOptimization() throws {