diff --git a/Sources/_StringProcessing/Optimizations/AutoPossessification.swift b/Sources/_StringProcessing/Optimizations/AutoPossessification.swift index e45ebb651..edd6c0b5e 100644 --- a/Sources/_StringProcessing/Optimizations/AutoPossessification.swift +++ b/Sources/_StringProcessing/Optimizations/AutoPossessification.swift @@ -12,25 +12,30 @@ internal import _RegexParser extension DSLList { - /// Finds the required first atom of the node at `position`. On return, - /// `position` is always at the last node of that node's subtree, regardless - /// of how early the search stopped, so callers can safely advance to the - /// next sibling. + /// Returns the required first atom of the node at `position`, leaving + /// `position` just past that node's entire subtree, so that a caller walking + /// a list of nodes reads the next sibling. + /// + /// A `nil` result means this node has no effect on what has to match first, + /// and `.some(nil)` means that no required first atom can be determined. + /// + /// 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. private func _requiredAtomImpl( _ position: inout Int, options: inout MatchingOptions, allowOptionsChanges: Bool ) -> DSLTree.Atom?? { - let start = position - defer { - position = start - skipNode(&position) - } - guard position < nodes.count else { return nil } - + + var endOfSubtree = position + skipNode(&endOfSubtree) + endOfSubtree += 1 + defer { position = endOfSubtree } + switch nodes[position] { case .atom(let atom): switch atom { @@ -46,26 +51,15 @@ extension DSLList { return atom } - // In a concatenation, the first definitive child provides the answer, - // and then we need to skip past (in some cases at least) the remaining - // concatenation elements. + // In a concatenation, the first definitive child provides the answer. case .concatenation(let count): - var result: DSLTree.Atom?? = nil - var i = 0 - while i < count { - i += 1 - position += 1 + position += 1 + for _ in 0..) throws { var list = pattern.program.list @@ -67,6 +67,58 @@ import Testing } } + /// The number of quantifications in `pattern` that auto-possessification + /// converts to possessive, and the total number of quantifications. + @available(macOS 26, *) + func possessificationCounts( + in pattern: String + ) throws -> (possessive: Int, total: Int) { + var list = try Regex(pattern).program.list + list.autoPossessify() + var possessive = 0 + var total = 0 + for node in list.nodes { + guard case .quantification(_, let kind) = node else { continue } + total += 1 + if kind.isExplicit && kind.quantificationKind?.ast == .possessive { + possessive += 1 + } + } + return (possessive, total) + } + + /// Auto-possessification walks a flat list of nodes, so a case that stops + /// looking at a node's children has to leave the cursor past the whole + /// subtree anyway. Losing track of it possessifies the wrong quantification, + /// which drops matches, or stops the traversal short, which silently gives + /// up the optimization. + @available(macOS 26, *) + @Test func partialAutoPossessify() throws { + // pattern, expected possessive count, expected quantification count + let expectations: [(String, Int, Int)] = [ + // Every quantification of a concatenation participates, not just the + // first one. + (#"a+b\d+c"#, 2, 2), + (#"x*a+b"#, 2, 2), + (#"\w+\s\d+a"#, 2, 2), + // `\s?` is excluded by the following `b`, but `b{1,3}` isn't + // possessifiable, because `\s?` is allowed to match nothing. + (#"(b{1,3}\s?)b"#, 1, 2), + // Same, one level down: only `\+*` is excluded by what follows it. + (#"(?i)(?:a*\+*)A{2}"#, 1, 3), + // Nothing is known about what has to match after `[ab]`, so `a*` can't + // be possessified at any level. + (#"(?:a*[ab])b"#, 0, 1), + (#"(?:(?:a*[ab])x?)b"#, 1, 2), + ] + for (pattern, possessive, total) in expectations { + let counts = try possessificationCounts(in: pattern) + #expect( + counts == (possessive, total), + "Unexpected possessification in '\(pattern)'") + } + } + @available(macOS 26, *) @Test(arguments: [ #"(\w* {1})a"#, #"(\w* {2})a"#, #"(\w* {0} {1})a"#, #"(\w*)\s"#, #"((\w+)) b"#, @@ -88,7 +140,10 @@ import Testing } @available(macOS 26, *) - @Test(arguments: [#"(\w* {0})a"#, #"((\w* {0})){1}()a"#, #"(\w*)(?: {0})a"#]) + @Test(arguments: [#"(\w* {0})a"#, #"((\w* {0})){1}()a"#, #"(\w*)(?: {0})a"#, + // `\s*` can be possessified, but an optional quantifier hides what follows `\w*` + #"\w*(?:\s*)a"#, + ]) func noAutoPossessifyAcrossSkippedNode(pattern: String) throws { // The zero-width quantification doesn't hide the `a` that follows `\w*`. var list = try Regex(pattern).program.list