RegexBuilder infinite loop when nullable capture starts with NegativeLookahead

In Swift 6.4 or later, a RegexBuilder pattern can hang when an unbounded quantifier repeats a body that can match the empty string, where that body begins with NegativeLookahead.

I've opened a corresponding issue and PR to resolve the issue in swift-experimental-string-processing. See below for a reproduction and a workaround.

The regression affects apps running on OS 27 built with Xcode 27, which includes Swift 6.4. Running apps built with Xcode 27 on OS 26 or earlier demonstrates the expected behavior.

Links:

Reproduction

In the reducer below, matching "A" repeatedly invokes the capture transform with an empty substring without advancing through the input.

import RegexBuilder

let regex = Regex {
    ZeroOrMore {
        Capture {
            NegativeLookahead { "a" }
            ZeroOrMore(.digit)
        } transform: { String($0) }   // invoked repeatedly with ""
    }
}

_ = "A".matches(of: regex)   // never returns

Reduced string form:

_ = try! Regex(#"(?:(?!a)\d*)*"#).firstMatch(in: "A")   // never returns

The issue is in the same forward-progress class as PR #851, which skips a nullable quantification's child subtree. Lookaround groups need the same treatment.

The regression first appears in Swift 6.4-dev toolchains. I observed the issue in code running on iOS 27 beta 1 (24A5355q), then traced the regression to PR #849 in swift-experimental-string-processing.

Workaround

In the meantime, wrap the capture contents in Optionally { }:

import RegexBuilder

let digits = Regex {
    NegativeLookahead { "a" }
    ZeroOrMore(.digit)
}

let regex = Regex {
    ZeroOrMore {
        Capture {
            Optionally {
                digits
            }
        } transform: { String($0) }
    }
}

_ = "A".matches(of: regex)

Thanks for the report.

I built a small Swift command-line tool with both snippets exactly as you posted them. I ran it on Swift 6.3.2 / Xcode 26.5 / macOS 26.5.1, the current shipping releases.

On shipping, both patterns complete:

  • First snippet (NegativeLookahead { "a" } + ZeroOrMore(.digit) inside ZeroOrMore { Capture { ... } }): the transform is invoked twice with the empty substring, then matches(of: regex) returns 2 matches.
  • Second snippet (Optionally wrapper): same result. Transform invoked twice with "", 2 matches.

Your observation about the transform being invoked with an empty substring is present on shipping as well. The difference is that on shipping the engine advances past the zero-width match and the matcher terminates after two invocations. On your environment (Swift 6.4 / OS 27 / Xcode 27) it apparently does not.

That points to a regression in the beta toolchain rather than a long-standing RegexBuilder issue. Feedback Assistant is the channel for beta-toolchain regressions: https://developer.apple.com/feedback-assistant/

RegexBuilder infinite loop when nullable capture starts with NegativeLookahead
 
 
Q