Skip to content

Commit 8e9ae5d

Browse files
committed
gh-155985: Do not fuse a set difference under IGNORECASE with LOCALE
_fuse_difference() rewrites <flat charset A> (?<![B]) into the single charset [NEGATE] B [NEGATE] A. That is sound where the engine walks a set once, but SRE(charset_loc_ignore) walks it twice, once with the lowercased character and once with the uppercased one, and matches if either walk does. The second walk starts with the NEGATE polarity reset, so a character that matches B in one case and A in the other escapes the subtraction. The pass did not see the compile flags, so it could not tell the two apart. Thread the effective flags through optimize() and _walk(), combining them for a scoped group, and skip the fusion when IGNORECASE and LOCALE are both set, the condition _compiler.py already uses to emit IN_LOC_IGNORE.
1 parent a7bb524 commit 8e9ae5d

3 files changed

Lines changed: 28 additions & 7 deletions

File tree

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: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -467,10 +467,12 @@ def _fuse_branch(av):
467467
items += cs
468468
return items if tail is None else items + tail
469469

470-
def _fuse_difference(data):
470+
def _fuse_difference(data, flags):
471471
# Replace <flat charset A> (?<![B1]) (?<![B2]) ... with the single charset
472472
# [NEGATE] B1 B2 ... [NEGATE] A. Each negative lookbehind over a flat
473473
# charset subtracts its set from the character A matches.
474+
if flags & SRE_FLAG_IGNORECASE and flags & SRE_FLAG_LOCALE:
475+
return
474476
out = []
475477
head = None # _flat_items(A) for the fused difference now at out[-1]
476478
subtrahend = None # its accumulated B items, or None when not fusing
@@ -493,17 +495,20 @@ def _fuse_difference(data):
493495
out.append((op, av))
494496
data[:] = out
495497

496-
def _walk(seq):
498+
def _walk(seq, flags):
497499
for i, (op, av) in enumerate(seq):
500+
subflags = flags
501+
if op is SUBPATTERN:
502+
subflags = _combine_flags(flags, av[1], av[2])
498503
for sub in _subpatterns(op, av):
499-
_walk(sub.data)
504+
_walk(sub.data, subflags)
500505
if op is BRANCH:
501506
items = _fuse_branch(av)
502507
if items is not None:
503508
seq[i] = (IN, items)
504-
_fuse_difference(seq)
509+
_fuse_difference(seq, flags)
505510

506-
def optimize(pattern):
511+
def optimize(pattern, flags):
507512
"""Rewrite a parsed pattern in place and return it."""
508-
_walk(pattern.data)
513+
_walk(pattern.data, flags)
509514
return pattern

Lib/test/test_re.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,6 +1558,22 @@ def test_set_operations(self):
15581558
self.assertEqual(w.filename, __file__)
15591559
self.assertEqual(re.findall(r'[~~1]', s), list('1~'))
15601560

1561+
def test_difference_not_fused_with_locale_ignorecase(self):
1562+
# IN_LOC_IGNORE tests the whole set once per locale case, so a fused
1563+
# difference, which relies on an embedded NEGATE, cannot be used.
1564+
IL = re.IGNORECASE | re.LOCALE
1565+
# [b] matches b'B' under these flags, so the lookbehind must reject it.
1566+
self.assertTrue(re.fullmatch(rb'[b]', b'B', IL))
1567+
self.assertIsNone(re.fullmatch(rb'\w(?<!b)', b'B', IL))
1568+
# IGNORECASE may also be scoped to the group.
1569+
self.assertIsNone(re.fullmatch(rb'(?i:\w(?<!b))', b'B', re.LOCALE))
1570+
# [0-m] matches b'N', so the difference operator must exclude it.
1571+
self.assertTrue(re.fullmatch(rb'[0-m]', b'N', IL))
1572+
self.assertIsNone(re.fullmatch(rb'[A-z--[0-m]]', b'N', IL))
1573+
# Without both flags the fusion is still applied.
1574+
self.assertTrue(re.fullmatch(rb'\w(?<!b)', b'B'))
1575+
self.assertTrue(re.fullmatch(rb'[a-c--[b]]', b'a'))
1576+
15611577
def test_search_coverage(self):
15621578
self.assertEqual(re.search(r"\s(b)", " b").group(1), "b")
15631579
self.assertEqual(re.search(r"a\s", "a ").group(0), "a ")

0 commit comments

Comments
 (0)