Skip to content

Commit c6b58ec

Browse files
gh-153044: Auto-possessify greedy repeats with a disjoint continuation
Add an optimizer pass that turns a greedy repeat into a possessive one when this cannot change what the pattern matches: if the repeated atom and every character that could follow the repeat are disjoint, backtracking into the repeat is always futile. For example "a+b" is compiled as if it were "a++b". PCRE2 calls this "auto-possessification". The analysis only proves disjointness, so any imprecision can merely cost an optimization, never change a match result. It speeds up backtracking-heavy failures whose tail is a character set or category (not covered by the REPEAT_ONE fast path) severalfold and defuses some catastrophic backtracking. Category atoms are decided by the engine itself through a new helper, _sre.category_matches(), so every engine category is decided exactly: \p{Lu}+x possessifies, and a category and its complement are always disjoint, so \p{X}+\P{X} possessifies for every X. Under IGNORECASE only the fold-invariant categories keep their meaning, and the analysis evaluates a fused set-operation charset exactly, so [\w--\d]+\d and [a-z--b]+b possessify too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ed370d3 commit c6b58ec

7 files changed

Lines changed: 513 additions & 10 deletions

File tree

Doc/whatsnew/3.16.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,12 @@ re
423423
(Contributed by Serhiy Storchaka in :gh:`152033` and Pieter Eendebak in
424424
:gh:`152056`.)
425425

426+
* A greedy repeat is now compiled as possessive when the repeated characters
427+
and the characters that can follow it are provably disjoint (for example
428+
``\d+\.`` is compiled as if it were ``\d++\.``), which makes failing and
429+
heavily backtracking matches severalfold faster.
430+
(Contributed by Serhiy Storchaka in :gh:`153044`.)
431+
426432
module_name
427433
-----------
428434

Lib/re/_compiler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ def _code(p, flags):
221221
flags = p.state.flags | flags
222222

223223
# run the optimizer passes over the parsed pattern
224-
optimize(p)
224+
optimize(p, flags)
225225

226226
code = []
227227

Lib/re/_optimizer.py

Lines changed: 344 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import _sre
1919
from . import _parser
20+
from ._casefix import _EXTRA_CASES
2021
from ._constants import *
2122

2223
_CHARSET_ALL = [(NEGATE, None)]
@@ -428,6 +429,319 @@ def _compile_info(code, pattern, flags):
428429
_compile_charset(charset, flags, code)
429430
code[skip] = len(code) - skip
430431

432+
433+
# --- Auto-possessification pass ---------------------------------------------
434+
435+
_REPEAT_CODES = frozenset({MIN_REPEAT, MAX_REPEAT, POSSESSIVE_REPEAT})
436+
_POSSESSIFY_UNITS = frozenset({LITERAL, NOT_LITERAL, ANY, ANY_ALL, IN, CATEGORY})
437+
438+
# \d, \w, \s and the line break category as unions of disjoint "atoms":
439+
# d=digit, l=word non-digit, b=line break, s=space non-line-break, o=other.
440+
# digit<=word, linebreak<=space and word disjoint from space hold for both
441+
# ASCII and Unicode, so disjoint atom sets mean really disjoint categories.
442+
_CAT_UNIVERSE = frozenset('dlbso')
443+
_CAT_ATOMS = {
444+
CATEGORY_DIGIT: frozenset('d'),
445+
CATEGORY_WORD: frozenset('dl'),
446+
CATEGORY_SPACE: frozenset('bs'),
447+
CATEGORY_LINEBREAK: frozenset('b'),
448+
}
449+
_CAT_ATOMS.update({
450+
CATEGORY_NOT_DIGIT: _CAT_UNIVERSE - _CAT_ATOMS[CATEGORY_DIGIT],
451+
CATEGORY_NOT_WORD: _CAT_UNIVERSE - _CAT_ATOMS[CATEGORY_WORD],
452+
CATEGORY_NOT_SPACE: _CAT_UNIVERSE - _CAT_ATOMS[CATEGORY_SPACE],
453+
CATEGORY_NOT_LINEBREAK: _CAT_UNIVERSE - _CAT_ATOMS[CATEGORY_LINEBREAK],
454+
})
455+
456+
_ASCII_SPACE = frozenset(b' \t\n\r\f\v')
457+
_ASCII_WORD = frozenset(b'_') | frozenset(
458+
range(0x30, 0x3a)) | frozenset(range(0x41, 0x5b)) | frozenset(range(0x61, 0x7b))
459+
_PROBE_LIMIT = 64 # cap on the size of a finite atom used as a witness set
460+
461+
def _tolower(c, flags):
462+
if flags & SRE_FLAG_UNICODE:
463+
return _sre.unicode_tolower(c)
464+
return _sre.ascii_tolower(c)
465+
466+
def _fold_set(c, flags):
467+
# Code points witnessing what LITERAL c matches: tolower() of any match
468+
# lies here and each element is itself a match (simple tolower plus
469+
# _EXTRA_CASES, like the IGNORECASE matcher).
470+
if not (flags & SRE_FLAG_IGNORECASE) or flags & SRE_FLAG_LOCALE:
471+
return (c,)
472+
lo = _tolower(c, flags)
473+
if flags & SRE_FLAG_UNICODE:
474+
extra = _EXTRA_CASES.get(lo)
475+
if extra:
476+
return (lo, *extra)
477+
return (lo,)
478+
479+
def _lit_matches(d, c, flags):
480+
# Whether LITERAL d matches input code point c.
481+
if not (flags & SRE_FLAG_IGNORECASE) or flags & SRE_FLAG_LOCALE:
482+
return c == d
483+
return _tolower(c, flags) in _fold_set(d, flags)
484+
485+
# Categories whose membership is invariant under case folding (verified over
486+
# the full range); the others cannot be decided under IGNORECASE, where a
487+
# charset member is matched against the lowercased character.
488+
_FOLD_CLOSED = frozenset({
489+
CATEGORY_DIGIT, CATEGORY_WORD, CATEGORY_SPACE, CATEGORY_LINEBREAK,
490+
CATEGORY_NUMERIC, CATEGORY_PRINTABLE, CATEGORY_N, CATEGORY_LM,
491+
CATEGORY_NL, CATEGORY_NO, CATEGORY_CF, CATEGORY_Z, CATEGORY_ZS,
492+
CATEGORY_C, CATEGORY_CN, CATEGORY_XID_CONTINUE, CATEGORY_ASSIGNED,
493+
CATEGORY_BLANK, CATEGORY_GRAPH, CATEGORY_PRINT, CATEGORY_CASED,
494+
})
495+
_FOLD_CLOSED |= frozenset(CH_NEGATE[cat] for cat in _FOLD_CLOSED)
496+
497+
def _cat_matches(cat, c, flags):
498+
# Whether category cat matches code point c, decided by the engine's own
499+
# predicate; None if it depends on the runtime locale or on case folding.
500+
if flags & SRE_FLAG_LOCALE:
501+
return None
502+
if flags & SRE_FLAG_IGNORECASE and cat not in _FOLD_CLOSED:
503+
return None
504+
if flags & SRE_FLAG_UNICODE:
505+
cat = CH_UNICODE[cat]
506+
return _sre.category_matches(cat, c)
507+
508+
def _member_matches(op, av, c, flags):
509+
# Whether a charset member (op, av) matches code point c. None if unknown.
510+
if op is LITERAL:
511+
return _lit_matches(av, c, flags)
512+
if op is RANGE:
513+
lo, hi = av
514+
if lo <= c <= hi:
515+
return True
516+
if not (flags & SRE_FLAG_IGNORECASE) or flags & SRE_FLAG_LOCALE:
517+
return False
518+
if lo <= _tolower(c, flags) <= hi or any(lo <= x <= hi
519+
for x in _fold_set(c, flags)):
520+
return True
521+
return None # case folding into the range can't be ruled out cheaply
522+
if op is CATEGORY:
523+
return _cat_matches(av, c, flags)
524+
return None
525+
526+
def _atom_matches(op, av, c, flags):
527+
# Whether the one-character atom (op, av) matches code point c.
528+
# Returns None when it cannot be decided (callers treat that as "maybe").
529+
if op is LITERAL:
530+
return _lit_matches(av, c, flags)
531+
if op is NOT_LITERAL:
532+
return not _lit_matches(av, c, flags)
533+
if op is CATEGORY:
534+
return _cat_matches(av, c, flags)
535+
if op is ANY:
536+
return True if flags & SRE_FLAG_DOTALL else c != 0x0a
537+
if op is ANY_ALL:
538+
return True
539+
if op is IN:
540+
# Evaluate the charset the way the engine's charset() walk does:
541+
# NEGATE toggles the polarity, a member hit returns the current
542+
# polarity, and the end returns the complement of the final one
543+
# (this also covers difference-fused charsets, see _fuse_difference).
544+
ok = True
545+
results = set()
546+
for iop, iav in av:
547+
if iop is NEGATE:
548+
ok = not ok
549+
continue
550+
r = _member_matches(iop, iav, c, flags)
551+
if r:
552+
results.add(ok)
553+
break
554+
if r is None:
555+
results.add(ok) # may or may not hit this member
556+
else:
557+
results.add(not ok)
558+
if len(results) == 1:
559+
return results.pop()
560+
return None
561+
return None
562+
563+
def _finite_set(op, av, flags):
564+
# The set of code points the atom matches, if finite and small; else None.
565+
if op is LITERAL:
566+
return set(_fold_set(av, flags))
567+
if op is IN:
568+
if av and av[0] == (NEGATE, None):
569+
return None
570+
out = set()
571+
for iop, iav in av:
572+
if iop is LITERAL:
573+
out.update(_fold_set(iav, flags))
574+
elif iop is RANGE:
575+
if iav[1] - iav[0] >= _PROBE_LIMIT:
576+
return None
577+
for x in range(iav[0], iav[1] + 1):
578+
out.update(_fold_set(x, flags))
579+
elif iop is CATEGORY:
580+
if flags & SRE_FLAG_LOCALE or flags & SRE_FLAG_UNICODE:
581+
return None # Unicode/locale categories are not small
582+
if iav is CATEGORY_DIGIT:
583+
out.update(range(0x30, 0x3a))
584+
elif iav is CATEGORY_WORD:
585+
out.update(_ASCII_WORD)
586+
elif iav is CATEGORY_SPACE:
587+
out.update(_ASCII_SPACE)
588+
elif iav is CATEGORY_LINEBREAK:
589+
out.add(0x0a)
590+
else:
591+
return None # a negated ASCII category is not small
592+
else:
593+
return None
594+
if len(out) > _PROBE_LIMIT:
595+
return None
596+
return out
597+
return None
598+
599+
def _cat_atom_set(op, av):
600+
# The dlbso atom set the atom matches, if it is a bare category or a
601+
# charset of categories (the first member claiming an atom decides it
602+
# with the current NEGATE polarity, the end claims the rest).
603+
if op is CATEGORY:
604+
return _CAT_ATOMS.get(av)
605+
if op is not IN:
606+
return None
607+
ok = True
608+
decided = set()
609+
matched = set()
610+
for iop, iav in av:
611+
if iop is NEGATE:
612+
ok = not ok
613+
continue
614+
if iop is not CATEGORY:
615+
if not ok:
616+
# a non-category member of a fail segment only narrows the
617+
# set; ignoring it over-approximates, which stays sound
618+
continue
619+
return None
620+
atoms = _CAT_ATOMS.get(iav)
621+
if atoms is None:
622+
return None
623+
if ok:
624+
matched |= atoms - decided
625+
decided |= atoms
626+
if not ok:
627+
matched |= _CAT_UNIVERSE - decided
628+
return matched
629+
630+
def _as_single_category(op, av):
631+
# The category code if the atom is a bare category or a single-category
632+
# class, else None.
633+
if op is CATEGORY:
634+
return av
635+
if op is IN and len(av) == 1 and av[0][0] is CATEGORY:
636+
return av[0][1]
637+
return None
638+
639+
def _disjoint(atom, other, flags):
640+
# True only if atom and other provably cannot match a common character.
641+
if flags & SRE_FLAG_LOCALE and flags & SRE_FLAG_IGNORECASE:
642+
# case folding is decided by the runtime locale; prove nothing
643+
return False
644+
ca = _as_single_category(*atom)
645+
if ca is not None:
646+
cb = _as_single_category(*other)
647+
# a category and its complement are disjoint whatever they mean
648+
if cb is not None and cb == CH_NEGATE[ca]:
649+
return True
650+
if not (flags & SRE_FLAG_LOCALE):
651+
a1 = _cat_atom_set(*atom)
652+
if a1 is not None:
653+
a2 = _cat_atom_set(*other)
654+
if a2 is not None and a1.isdisjoint(a2):
655+
return True
656+
fa = _finite_set(*atom, flags)
657+
fb = _finite_set(*other, flags)
658+
if fa is not None and fb is not None:
659+
return fa.isdisjoint(fb)
660+
if fa is not None:
661+
return not any(_atom_matches(*other, c, flags) is not False for c in fa)
662+
if fb is not None:
663+
return not any(_atom_matches(*atom, c, flags) is not False for c in fb)
664+
return False
665+
666+
def _leading_atom(data):
667+
# The leading atom of a rigid body -- a concatenation of single-character
668+
# atoms with no internal choice. A repeat of it gives back only whole
669+
# iterations, so its leading atom is all the follower must avoid.
670+
lead = None
671+
for op, av in data:
672+
if op is SUBPATTERN and not av[1] and not av[2]:
673+
a = _leading_atom(av[3].data)
674+
elif op is ATOMIC_GROUP:
675+
a = _leading_atom(av.data)
676+
elif op in _POSSESSIFY_UNITS:
677+
a = (op, av)
678+
else:
679+
return None
680+
if a is None:
681+
return None
682+
if lead is None:
683+
lead = a
684+
return lead
685+
686+
def _first_consumers(seq, i, flags, cont):
687+
# Atoms for every character that could be consumed at position i of seq;
688+
# cont is the same for what follows seq. None if it can't be analyzed.
689+
acc = []
690+
n = len(seq)
691+
while i < n:
692+
op, av = seq[i]
693+
if op in _POSSESSIFY_UNITS:
694+
acc.append((op, av))
695+
return acc
696+
if op is SUBPATTERN:
697+
if av[1] or av[2]:
698+
return None # flag-scoping group: atoms can't carry their flags
699+
after = _first_consumers(seq, i + 1, flags, cont)
700+
if after is None:
701+
return None
702+
inner = _first_consumers(av[3].data, 0, flags, after)
703+
return None if inner is None else acc + inner
704+
if op is ATOMIC_GROUP:
705+
after = _first_consumers(seq, i + 1, flags, cont)
706+
if after is None:
707+
return None
708+
inner = _first_consumers(av.data, 0, flags, after)
709+
return None if inner is None else acc + inner
710+
if op is BRANCH:
711+
after = _first_consumers(seq, i + 1, flags, cont)
712+
if after is None:
713+
return None
714+
for alt in av[1]:
715+
a = _first_consumers(alt.data, 0, flags, after)
716+
if a is None:
717+
return None
718+
acc += a
719+
return acc
720+
if op in _REPEAT_CODES:
721+
mn, mx, p = av
722+
sub = _first_consumers(p.data, 0, flags, None)
723+
if sub is None:
724+
return None
725+
acc += sub
726+
if mn == 0:
727+
i += 1
728+
continue
729+
return acc
730+
if op is AT and av is AT_END_STRING:
731+
# \z matches only at the very end; backtracking the repeat moves
732+
# earlier and can never satisfy it, so nothing need be disjoint.
733+
return acc
734+
if op is AT and av is AT_END:
735+
# $ is like \z but also matches before a '\n'. Only MULTILINE
736+
# exposes an interior one to backtracking, and then only if the
737+
# repeat can match '\n'.
738+
if flags & SRE_FLAG_MULTILINE:
739+
return acc + [(LITERAL, 0x0a)]
740+
return acc
741+
return None # assertion, anchor, group reference, ... -> give up
742+
return None if cont is None else acc + cont
743+
744+
431745
# Difference-fusion peephole: rewrite [A--B]-style A(?<![B]) into a single
432746
# charset (see the engine's NEGATE polarity toggle).
433747
def _subpatterns(op, av):
@@ -493,17 +807,41 @@ def _fuse_difference(data):
493807
out.append((op, av))
494808
data[:] = out
495809

496-
def _walk(seq):
810+
def _walk(seq, flags, cont):
811+
# Rewrite the sequence in place: fuse set-operation charsets (see
812+
# _fuse_branch and _fuse_difference) and turn greedy repeats possessive
813+
# where the repeated atom and every possible follower are disjoint.
497814
for i, (op, av) in enumerate(seq):
498-
for sub in _subpatterns(op, av):
499-
_walk(sub.data)
500-
if op is BRANCH:
815+
if op is SUBPATTERN:
816+
if av[1] or av[2]:
817+
# flag-scoping group: optimize inside it, but its boundary is
818+
# opaque since atoms there match under different flags
819+
_walk(av[3].data, _combine_flags(flags, av[1], av[2]), None)
820+
else:
821+
_walk(av[3].data, flags,
822+
_first_consumers(seq, i + 1, flags, cont))
823+
elif op is BRANCH:
824+
after = _first_consumers(seq, i + 1, flags, cont)
825+
for alt in av[1]:
826+
_walk(alt.data, flags, after)
501827
items = _fuse_branch(av)
502828
if items is not None:
503829
seq[i] = (IN, items)
830+
else:
831+
# ATOMIC_GROUP / ASSERT(_NOT) / GROUPREF_EXISTS / repeat body have an
832+
# opaque boundary -- optimize inside with no follower context.
833+
for sub in _subpatterns(op, av):
834+
_walk(sub.data, flags, None)
835+
if op is MAX_REPEAT:
836+
atom = _leading_atom(av[2].data)
837+
if atom is not None:
838+
follow = _first_consumers(seq, i + 1, flags, cont)
839+
if follow is not None and \
840+
all(_disjoint(atom, f, flags) for f in follow):
841+
seq[i] = (POSSESSIVE_REPEAT, av)
504842
_fuse_difference(seq)
505843

506-
def optimize(pattern):
844+
def optimize(pattern, flags):
507845
"""Rewrite a parsed pattern in place and return it."""
508-
_walk(pattern.data)
846+
_walk(pattern.data, flags, [])
509847
return pattern

0 commit comments

Comments
 (0)