backrefs 8.2__tar.gz → 8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {backrefs-8.2 → backrefs-8.3}/PKG-INFO +1 -1
  2. {backrefs-8.2 → backrefs-8.3}/backrefs/__meta__.py +1 -1
  3. {backrefs-8.2 → backrefs-8.3}/backrefs/_bre_parse.py +179 -28
  4. {backrefs-8.2 → backrefs-8.3}/backrefs/_bregex_parse.py +176 -91
  5. {backrefs-8.2 → backrefs-8.3}/backrefs/bre.py +3 -2
  6. {backrefs-8.2 → backrefs-8.3}/backrefs/bregex.py +3 -2
  7. {backrefs-8.2 → backrefs-8.3}/backrefs/uniprops/__init__.py +99 -62
  8. {backrefs-8.2 → backrefs-8.3}/docs/src/dictionary/en-custom.txt +3 -0
  9. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/about/changelog.md +34 -0
  10. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/refs.md +16 -3
  11. {backrefs-8.2 → backrefs-8.3}/tests/test_binary.py +41 -0
  12. {backrefs-8.2 → backrefs-8.3}/tests/test_block.py +53 -0
  13. {backrefs-8.2 → backrefs-8.3}/tests/test_bre.py +248 -8
  14. {backrefs-8.2 → backrefs-8.3}/tests/test_bregex.py +108 -4
  15. {backrefs-8.2 → backrefs-8.3}/tests/test_generalcategory.py +45 -0
  16. {backrefs-8.2 → backrefs-8.3}/tools/unipropgen.py +11 -0
  17. backrefs-8.2/tools/unicodedata/13.0.0.zip +0 -0
  18. {backrefs-8.2 → backrefs-8.3}/.coveragerc +0 -0
  19. {backrefs-8.2 → backrefs-8.3}/.gitignore +0 -0
  20. {backrefs-8.2 → backrefs-8.3}/.pyspelling.yml +0 -0
  21. {backrefs-8.2 → backrefs-8.3}/LICENSE.md +0 -0
  22. {backrefs-8.2 → backrefs-8.3}/README.md +0 -0
  23. {backrefs-8.2 → backrefs-8.3}/backrefs/__init__.py +0 -0
  24. {backrefs-8.2 → backrefs-8.3}/backrefs/_bregex_typing.py +0 -0
  25. {backrefs-8.2 → backrefs-8.3}/backrefs/py.typed +0 -0
  26. {backrefs-8.2 → backrefs-8.3}/backrefs/util.py +0 -0
  27. {backrefs-8.2 → backrefs-8.3}/docs/src/dictionary/en-python.txt +0 -0
  28. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/.snippets/links.md +0 -0
  29. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/about/contributing.md +0 -0
  30. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/about/license.md +0 -0
  31. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/about/security.md +0 -0
  32. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/index.md +0 -0
  33. {backrefs-8.2 → backrefs-8.3}/docs/src/markdown/usage.md +0 -0
  34. {backrefs-8.2 → backrefs-8.3}/docs/theme/announce.html +0 -0
  35. {backrefs-8.2 → backrefs-8.3}/hatch_build.py +0 -0
  36. {backrefs-8.2 → backrefs-8.3}/pyproject.toml +0 -0
  37. {backrefs-8.2 → backrefs-8.3}/tests/__init__.py +0 -0
  38. {backrefs-8.2 → backrefs-8.3}/tests/test_age.py +0 -0
  39. {backrefs-8.2 → backrefs-8.3}/tests/test_bidiclass.py +0 -0
  40. {backrefs-8.2 → backrefs-8.3}/tests/test_bidipairedbrackettype.py +0 -0
  41. {backrefs-8.2 → backrefs-8.3}/tests/test_canonicalcombiningclass.py +0 -0
  42. {backrefs-8.2 → backrefs-8.3}/tests/test_decompositiontype.py +0 -0
  43. {backrefs-8.2 → backrefs-8.3}/tests/test_eastasianwidth.py +0 -0
  44. {backrefs-8.2 → backrefs-8.3}/tests/test_graphemeclusterbreak.py +0 -0
  45. {backrefs-8.2 → backrefs-8.3}/tests/test_hangulsyllabletype.py +0 -0
  46. {backrefs-8.2 → backrefs-8.3}/tests/test_indicpositionalcategory.py +0 -0
  47. {backrefs-8.2 → backrefs-8.3}/tests/test_indicsylabiccategory.py +0 -0
  48. {backrefs-8.2 → backrefs-8.3}/tests/test_joininggroup.py +0 -0
  49. {backrefs-8.2 → backrefs-8.3}/tests/test_joiningtype.py +0 -0
  50. {backrefs-8.2 → backrefs-8.3}/tests/test_linebreak.py +0 -0
  51. {backrefs-8.2 → backrefs-8.3}/tests/test_numerictype.py +0 -0
  52. {backrefs-8.2 → backrefs-8.3}/tests/test_numericvalue.py +0 -0
  53. {backrefs-8.2 → backrefs-8.3}/tests/test_quickcheck.py +0 -0
  54. {backrefs-8.2 → backrefs-8.3}/tests/test_script.py +0 -0
  55. {backrefs-8.2 → backrefs-8.3}/tests/test_scriptextensions.py +0 -0
  56. {backrefs-8.2 → backrefs-8.3}/tests/test_sentencebreak.py +0 -0
  57. {backrefs-8.2 → backrefs-8.3}/tests/test_uniprops.py +0 -0
  58. {backrefs-8.2 → backrefs-8.3}/tests/test_versions.py +0 -0
  59. {backrefs-8.2 → backrefs-8.3}/tests/test_verticalorientation.py +0 -0
  60. {backrefs-8.2 → backrefs-8.3}/tests/test_wordbreak.py +0 -0
  61. {backrefs-8.2 → backrefs-8.3}/tools/__init__.py +0 -0
  62. {backrefs-8.2 → backrefs-8.3}/tools/pycon.py +0 -0
  63. {backrefs-8.2 → backrefs-8.3}/tools/unicodedata/14.0.0.zip +0 -0
  64. {backrefs-8.2 → backrefs-8.3}/tools/unicodedata/15.0.0.zip +0 -0
  65. {backrefs-8.2 → backrefs-8.3}/tools/unicodedata/15.1.0.zip +0 -0
  66. {backrefs-8.2 → backrefs-8.3}/tools/unicodedata/16.0.0.zip +0 -0
  67. {backrefs-8.2 → backrefs-8.3}/tools/unicodedata/17.0.0.zip +0 -0
  68. {backrefs-8.2 → backrefs-8.3}/tools/unicodedata/LICENSE +0 -0
  69. {backrefs-8.2 → backrefs-8.3}/tools/unidatadownload.py +0 -0
  70. {backrefs-8.2 → backrefs-8.3}/zensical.yml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backrefs
3
- Version: 8.2
3
+ Version: 8.3
4
4
  Summary: A wrapper around re and regex that adds additional back references.
5
5
  Project-URL: Homepage, https://github.com/facelessuser/backrefs
6
6
  Author-email: Isaac Muse <Isaac.Muse@gmail.com>
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
193
193
  return Version(major, minor, micro, release, pre, post, dev)
194
194
 
195
195
 
196
- __version_info__ = Version(8, 2, 0, "final")
196
+ __version_info__ = Version(8, 3, 0, "final")
197
197
  __version__ = __version_info__._get_canonical()
@@ -6,18 +6,13 @@ Copyright (c) 2011 - 2020 Isaac Muse <isaacmuse@gmail.com>
6
6
  """
7
7
  from __future__ import annotations
8
8
  import re as _re
9
- import sys
9
+ import re._parser as _parser # type: ignore[import]
10
10
  import copyreg as _copyreg
11
11
  from . import util as _util
12
12
  from .util import PatternError
13
13
  from . import uniprops as _uniprops
14
14
  from typing import Generic, AnyStr, Match, Any, Pattern, cast
15
15
 
16
- if sys.version_info >= (3, 11):
17
- import re._parser as _parser # type: ignore[import]
18
- else:
19
- import sre_parse as _parser
20
-
21
16
  __all__ = ("ReplaceTemplate", "PatternError")
22
17
 
23
18
  _ASCII_LETTERS = frozenset(
@@ -76,11 +71,13 @@ class _SearchParser(Generic[AnyStr]):
76
71
  _re_start_wb = r"\b(?=\w)"
77
72
  _re_end_wb = r"\b(?<=\w)"
78
73
  _line_break = r'(?:\r\n|(?!\r\n)[\n\v\f\r\x85\u2028\u2029])'
79
- _bytes_line_break = r'(?>\r\n|[\n\v\f\r\x85])' if _util.PY311 else r'(?:\r\n|(?!\r\n)[\n\v\f\r\x85])'
74
+ _bytes_line_break = r'(?>\r\n|[\n\v\f\r\x85])'
80
75
  _grapheme_cluster = r'(?:{}{}*(?!{}))'
81
76
 
82
77
  verbose: bool
83
78
  unicode: bool
79
+ named_unicode: tuple[str, int]
80
+ negated_members: list[str]
84
81
  ascii: bool # noqa: A003
85
82
  is_bytes: bool
86
83
  search: AnyStr
@@ -283,6 +280,7 @@ class _SearchParser(Generic[AnyStr]):
283
280
  current.extend(self.unicode_name(text, in_group))
284
281
  if in_group:
285
282
  self.found_named_unicode = True
283
+ self.named_unicode = (f'\\N{{{text}}}', ord(_util.unicode_char(text)))
286
284
  else:
287
285
  current.extend(["\\", t])
288
286
  return current
@@ -418,27 +416,57 @@ class _SearchParser(Generic[AnyStr]):
418
416
  self.verbose = verbose
419
417
  self.unicode = unicode_flag
420
418
 
421
- if t == ")":
422
- current.append(t)
419
+ current.append(t)
423
420
  return current
424
421
 
425
422
  def char_groups(self, t: str, i: _util.StringIter) -> list[str]:
426
423
  """Handle character groups."""
427
424
 
428
- current = []
425
+ current = [] # type: list[str]
429
426
  pos = i.index - 1
430
427
  found = False
431
428
  escaped = False
429
+ escape_index = 0
432
430
  first = 0
433
431
  found_property = False
432
+ # Index of an unescaped `-` in `current` that is acting as a range operator.
433
+ range_dash = -1
434
+ # Whether the last member could start a range if followed by `-`.
435
+ range_start = False
436
+ # The last member's original text, position, and code point if it is a named character
437
+ # beyond the byte range of a byte string.
438
+ start = ('', 0, None) # type: tuple[str, int, int | None]
434
439
  self.found_property = False
435
440
  self.found_named_unicode = False
441
+ self.negated_members = []
442
+ negated_set = False
443
+
444
+ def add_member(raw: str, index: int, value: int | None) -> None:
445
+ """Track a member that was just added, which may start or end a range."""
446
+
447
+ nonlocal range_dash, range_start, start
448
+
449
+ if range_dash != -1:
450
+ # This member ends a range.
451
+ start_raw, start_index, start_value = start
452
+ if start_value is not None or value is not None:
453
+ end = self.get_named_byte_range(start_raw, raw, start_value, value, start_index)
454
+ if end is None:
455
+ current[range_dash] = ''
456
+ else:
457
+ current[-1] = end
458
+ range_dash = -1
459
+ range_start = False
460
+ else:
461
+ start = (raw, index, value)
462
+ range_start = True
436
463
 
437
464
  try:
438
465
  while True:
439
466
  # Prevent POSIX/Unicode class from being part of a range.
440
467
  if self.found_property and t == '-':
441
468
  current.append(_re.escape(t))
469
+ add_member(t, i.index - 1, None)
442
470
  pos += 1
443
471
  t = next(i)
444
472
  self.found_property = False
@@ -448,15 +476,26 @@ class _SearchParser(Generic[AnyStr]):
448
476
 
449
477
  if not escaped and t == "\\":
450
478
  escaped = True
479
+ escape_index = i.index - 1
451
480
  elif escaped:
452
481
  escaped = False
453
482
  idx = len(current) - 1
454
483
  current.extend(self.reference(t, i, True))
455
484
  if self.found_property:
456
485
  # Prevent Unicode class from being part of a range.
457
- if idx >= 0 and current[idx] == '-':
486
+ if idx == range_dash:
458
487
  current[idx] = _re.escape('-')
459
488
  found_property = True
489
+ range_dash = -1
490
+ range_start = False
491
+ elif t == 'N':
492
+ raw, code = self.named_unicode
493
+ add_member(raw, escape_index, code if self.is_bytes and code > 0xFF else None)
494
+ else:
495
+ digits = self.get_escape_digits(i, t)
496
+ if digits:
497
+ current.append(digits)
498
+ add_member(f'\\{t}{digits}', escape_index, None)
460
499
  elif t == "[" and not found:
461
500
  found = True
462
501
  first = pos
@@ -470,22 +509,44 @@ class _SearchParser(Generic[AnyStr]):
470
509
  i.rewind(i.index - index)
471
510
  if prop is not None:
472
511
  value = self.unicode_props(prop[0], prop[1], in_group=True)
473
- if current[-1] == '-':
512
+ # Prevent POSIX class from being part of a range.
513
+ if len(current) - 1 == range_dash:
474
514
  current[-1] = _re.escape('-')
475
515
  current.extend(value)
476
516
  found_property = True
517
+ self.found_property = True
477
518
  pos = i.index - 2
519
+ range_dash = -1
520
+ range_start = False
478
521
  else:
479
522
  current.append(t)
480
- elif t == "^" and found and (pos == first + 1):
523
+ add_member(t, index - 1, None)
524
+ elif t == "^" and found and (pos == first + 1) and len(current) == 1:
525
+ # Only a `^` directly after the opening `[` negates the set.
481
526
  first = pos
482
527
  current.append(t)
528
+ negated_set = True
483
529
  elif t == "]" and found and (pos != first + 1):
484
530
  found = False
485
531
  current.append(t)
486
532
  break
487
- else:
533
+ elif t == '-' and range_start:
534
+ # A `-` after a member starts a range, unless it is followed by `]`.
488
535
  current.append(t)
536
+ range_dash = len(current) - 1
537
+ range_start = False
538
+ else:
539
+ if (
540
+ t == '^' and
541
+ (found_property or self.found_named_unicode) and
542
+ ''.join(current) in ('[', '[^')
543
+ ):
544
+ # Everything before this was a property that resolved to nothing,
545
+ # so escape `^` to prevent it from being treated as a negation.
546
+ current.append(_re.escape(t))
547
+ else:
548
+ current.append(t)
549
+ add_member(t, i.index - 1, None)
489
550
  pos += 1
490
551
  t = next(i)
491
552
  except StopIteration as e:
@@ -495,7 +556,9 @@ class _SearchParser(Generic[AnyStr]):
495
556
  # This will occur when a property's values exceed
496
557
  # either the Unicode char limit on a narrow system,
497
558
  # or the ASCII limit in a byte string pattern.
498
- if found_property or self.found_named_unicode:
559
+ if self.negated_members:
560
+ current = [self.negated_set_text(''.join(current), negated_set)]
561
+ elif found_property or self.found_named_unicode:
499
562
  temp = "".join(current)
500
563
  if temp == '[]':
501
564
  # We specified some properties, but they are all
@@ -508,6 +571,32 @@ class _SearchParser(Generic[AnyStr]):
508
571
 
509
572
  return current
510
573
 
574
+ def negated_set_text(self, text: str, negated: bool) -> str:
575
+ r"""
576
+ Get a character set that contains negated properties, such as `[a\P{Lu}]`.
577
+
578
+ Under case-insensitive matching, a negated property must be a negated set of the property's characters
579
+ (`[^...]`), as a set of the characters outside the property would also match the other case of the
580
+ characters in it. A set can't contain a negated set, so each part of the set is matched separately:
581
+ `[R\P{A}\P{B}]` becomes `(?:[R]|[^A]|[^B])`, and `[^R\P{A}\P{B}]` becomes `(?![R])(?=[A])[B]`.
582
+ """
583
+
584
+ members = text[2 if negated else 1:-1]
585
+ if members.startswith('^'):
586
+ # A literal `^` after the negating `^` would negate the members' own set.
587
+ members = '\\' + members
588
+
589
+ if not negated:
590
+ alternatives = [f'[^{m}]' for m in self.negated_members]
591
+ if members:
592
+ alternatives.insert(0, f'[{members}]')
593
+ return alternatives[0] if len(alternatives) == 1 else f"(?:{'|'.join(alternatives)})"
594
+
595
+ conditions = [f'(?=[{m}])' for m in self.negated_members[:-1]]
596
+ if members:
597
+ conditions.insert(0, f'(?![{members}])')
598
+ return ''.join(conditions) + f'[{self.negated_members[-1]}]'
599
+
511
600
  def normal(self, t: str, i: _util.StringIter) -> list[str]:
512
601
  """Handle normal chars."""
513
602
 
@@ -540,6 +629,60 @@ class _SearchParser(Generic[AnyStr]):
540
629
  return ['']
541
630
  return [f'\\{value:03o}' if value <= 0xFF else chr(value)]
542
631
 
632
+ def get_escape_digits(self, i: _util.StringIter, e: str) -> str:
633
+ r"""
634
+ Get the digits that are part of an escape in a character set (`\x`, `\u`, `\U`, or octal).
635
+
636
+ This allows an escape to be treated as a single member of the character set.
637
+ """
638
+
639
+ if e == 'x':
640
+ count, valid = 2, _HEX
641
+ elif e == 'u' and not self.is_bytes:
642
+ count, valid = 4, _HEX
643
+ elif e == 'U' and not self.is_bytes:
644
+ count, valid = 8, _HEX
645
+ elif e in _OCTAL:
646
+ count, valid = 2, _OCTAL
647
+ else:
648
+ return ''
649
+
650
+ digits = []
651
+ while count:
652
+ try:
653
+ c = next(i)
654
+ except StopIteration:
655
+ break
656
+ if c.lower() not in valid:
657
+ i.rewind(1)
658
+ break
659
+ digits.append(c)
660
+ count -= 1
661
+ return ''.join(digits)
662
+
663
+ @staticmethod
664
+ def get_named_byte_range(
665
+ start: str,
666
+ end: str,
667
+ start_value: int | None,
668
+ end_value: int | None,
669
+ index: int
670
+ ) -> str | None:
671
+ """
672
+ Get the end of a range in a byte string where either end is a named character beyond the byte range.
673
+
674
+ `start_value` and `end_value` are the code points of the ends that are named characters beyond the byte
675
+ range, and `None` for ends within the byte range. `start` and `end` are the original text of each end,
676
+ and `index` is where the range starts, both used for errors.
677
+
678
+ A range that ends beyond the byte range includes everything up to the end of the byte range, so return
679
+ the end to use in its place. A range that starts beyond the byte range matches nothing, so return `None`.
680
+ """
681
+
682
+ if start_value is not None and (end_value is None or end_value < start_value):
683
+ raise PatternError(f'bad character range {start}-{end} at position {index}')
684
+ return None if start_value is not None else '\\377'
685
+
543
686
  def unicode_props(
544
687
  self,
545
688
  props: str,
@@ -569,14 +712,23 @@ class _SearchParser(Generic[AnyStr]):
569
712
  else:
570
713
  mode = _uniprops.MODE_UNICODE
571
714
 
572
- v = _uniprops.get_unicode_property(props, prop_value, mode)
573
- if not in_group:
574
- if not v:
575
- v = f'^{_uniprops.ASCII_RANGE if self.is_bytes else _uniprops.UNICODE_RANGE}'
576
- v = f"[{v}]"
577
- properties = [v]
578
-
579
- return properties
715
+ # Negated properties are expressed as a negated set of the property's characters, so that they work with
716
+ # case-insensitive matching (see `negated_set_text`).
717
+ v, negated = _uniprops.get_unicode_property_parts(props, prop_value, mode)
718
+ everything = _uniprops.ASCII_RANGE if self.is_bytes else _uniprops.UNICODE_RANGE
719
+ if in_group:
720
+ if negated:
721
+ if not v:
722
+ # The inverse of a property that matches nothing is everything, which case-insensitive
723
+ # matching can't extend, so it can be a member of the set.
724
+ return [everything]
725
+ # Added when the set is complete.
726
+ self.negated_members.append(v)
727
+ return ['']
728
+ return [v]
729
+ if negated:
730
+ return [f'[^{v}]' if v else f'[{everything}]']
731
+ return [f'[{v}]' if v else f'[^{everything}]']
580
732
 
581
733
  def main_group(self, i: _util.StringIter) -> list[str]:
582
734
  """The main group: group 0."""
@@ -1183,7 +1335,7 @@ class _ReplaceParser(Generic[AnyStr]):
1183
1335
  count = len(self.span_stack)
1184
1336
  self.end_found = False
1185
1337
  try:
1186
- while not self.end_found:
1338
+ while True:
1187
1339
  t = next(i)
1188
1340
  if self.use_format and t in _CURLY_BRACKETS:
1189
1341
  self.handle_format(t, i)
@@ -1236,10 +1388,9 @@ class _ReplaceParser(Generic[AnyStr]):
1236
1388
  except StopIteration:
1237
1389
  self.result.append(t)
1238
1390
  raise
1239
- elif self.single_stack:
1240
- this_case = self.get_single_stack()
1241
- if this_case is not None:
1242
- self.result.append(self.convert_case(t, this_case))
1391
+ else:
1392
+ # The stack always has the case just added.
1393
+ self.result.append(self.convert_case(t, cast(int, self.get_single_stack())))
1243
1394
  except StopIteration:
1244
1395
  pass
1245
1396
 
@@ -33,8 +33,16 @@ _LETTERS_UNDERSCORE = _ASCII_LETTERS | frozenset(('_',))
33
33
  _WORD = _LETTERS_UNDERSCORE | _DIGIT
34
34
  _STANDARD_ESCAPES = frozenset(('a', 'b', 'f', 'n', 'r', 't', 'v'))
35
35
  _CURLY_BRACKETS = frozenset(('{', '}'))
36
- _PROPERTY_STRIP = frozenset((' ', '-', '_'))
37
- _PROPERTY = _WORD | _DIGIT | _PROPERTY_STRIP
36
+ # Character set grammar, as defined by Regex.
37
+ _ALNUM = _ASCII_LETTERS | _DIGIT
38
+ _PROPERTY_NAME = _ALNUM | frozenset((' ', '&', '_', '-', '.'))
39
+ _PROPERTY_VALUE = _ALNUM | frozenset((' ', '&', '_', '-', '.', '/'))
40
+ _NAMED_CHAR = _ALNUM | frozenset((' ', '-'))
41
+ _HEX_DIGITS = _HEX | frozenset(c.upper() for c in _HEX)
42
+ _HEX_ESCAPES = {'x': 2, 'u': 4, 'U': 8}
43
+ _SHORT_PROPERTIES = frozenset(('C', 'L', 'M', 'N', 'P', 'S', 'Z'))
44
+ _CLASS_ESCAPES = frozenset(('d', 'D', 'h', 's', 'S', 'w', 'W'))
45
+ _SET_OPS = frozenset(('||', '~~', '&&', '--'))
38
46
  _GLOBAL_FLAGS = frozenset(('b', 'e', 'p', 'r', 'V'))
39
47
  _SCOPED_FLAGS = frozenset(('a', 'f', 'i', 'L', 'm', 's', 'u', 'w', 'x'))
40
48
  _VERSIONS = frozenset(('0', '1'))
@@ -72,6 +80,7 @@ class _SearchParser(Generic[AnyStr]):
72
80
  version: int
73
81
  is_bytes: bool
74
82
  search: AnyStr
83
+ text: str
75
84
 
76
85
  def __init__(self, search: AnyStr, re_verbose: bool = False, re_version: int = 0) -> None:
77
86
  """Initialize."""
@@ -167,37 +176,6 @@ class _SearchParser(Generic[AnyStr]):
167
176
  current.extend(["\\", t])
168
177
  return current
169
178
 
170
- def get_posix(self, i: _util.StringIter) -> str | None:
171
- """Get POSIX."""
172
-
173
- index = i.index
174
- value = ['[']
175
- try:
176
- c = next(i)
177
- if c != ':':
178
- raise PatternError('Not a valid property!')
179
- else:
180
- value.append(c)
181
- c = next(i)
182
- if c == '^':
183
- value.append(c)
184
- c = next(i)
185
- while c != ':':
186
- if c not in _PROPERTY:
187
- raise PatternError('Not a valid property!')
188
- if c not in _PROPERTY_STRIP:
189
- value.append(c)
190
- c = next(i)
191
- value.append(c)
192
- c = next(i)
193
- if c != ']' or not value:
194
- raise PatternError('Unmatched ]')
195
- value.append(c)
196
- except Exception:
197
- i.rewind(i.index - index)
198
- value = []
199
- return ''.join(value) if value else None
200
-
201
179
  def get_comments(self, i: _util.StringIter) -> str | None:
202
180
  """Get comments."""
203
181
 
@@ -342,69 +320,175 @@ class _SearchParser(Generic[AnyStr]):
342
320
  current.append(t)
343
321
  return current
344
322
 
323
+ @staticmethod
324
+ def peek(i: _util.StringIter, count: int = 1) -> str:
325
+ """Get up to the next `count` characters without consuming them."""
326
+
327
+ chars = []
328
+ for _ in range(count):
329
+ try:
330
+ chars.append(next(i))
331
+ except StopIteration:
332
+ break
333
+ i.rewind(len(chars))
334
+ return ''.join(chars)
335
+
336
+ @staticmethod
337
+ def match(i: _util.StringIter, text: str) -> bool:
338
+ """Consume `text` if it is next."""
339
+
340
+ index = i.index
341
+ for c in text:
342
+ try:
343
+ if next(i) == c:
344
+ continue
345
+ except StopIteration:
346
+ pass
347
+ i.rewind(i.index - index)
348
+ return False
349
+ return True
350
+
351
+ @staticmethod
352
+ def consume(i: _util.StringIter, chars: frozenset[str], limit: int = -1) -> str:
353
+ """Consume characters while they are in `chars`, up to `limit` characters if given."""
354
+
355
+ value = []
356
+ while limit:
357
+ try:
358
+ c = next(i)
359
+ except StopIteration:
360
+ break
361
+ if c not in chars:
362
+ i.rewind(1)
363
+ break
364
+ value.append(c)
365
+ limit -= 1
366
+ return ''.join(value)
367
+
345
368
  def char_groups(self, t: str, i: _util.StringIter) -> list[str]:
346
369
  """Handle character groups."""
347
370
 
348
- current = []
349
- pos = i.index - 1
350
- found = 0
351
- sub_first = 0
352
- escaped = False
353
- first = 0
354
-
371
+ start = i.index - 1
355
372
  try:
356
- while True:
357
- if not escaped and t == "\\":
358
- escaped = True
359
- elif escaped:
360
- escaped = False
361
- current.extend(self.reference(t, i, True))
362
- elif t == "[" and not found:
363
- found += 1
364
- first = pos
365
- current.append(t)
366
- elif t == "[" and found and self.version == _regex.V1:
367
- # Start of sub char set found
368
- posix = None if self.is_bytes else self.get_posix(i)
369
- if posix:
370
- current.append(posix)
371
- pos = i.index - 2
372
- else:
373
- found += 1
374
- sub_first = pos
375
- current.append(t)
376
- elif t == "[":
377
- posix = None if self.is_bytes else self.get_posix(i)
378
- if posix:
379
- current.append(posix)
380
- pos = i.index - 2
381
- else:
382
- current.append(t)
383
- elif t == "^" and found == 1 and (pos == first + 1):
384
- # Found ^ at start of first char set; adjust 1st char position
385
- current.append(t)
386
- first = pos
387
- elif self.version == _regex.V1 and t == "^" and found > 1 and (pos == sub_first + 1):
388
- # Found ^ at start of sub char set; adjust 1st char sub position
389
- current.append(t)
390
- sub_first = pos
391
- elif t == "]" and found == 1 and (pos != first + 1):
392
- # First char set closed; log range
393
- current.append(t)
394
- found = 0
395
- break
396
- elif self.version == _regex.V1 and t == "]" and found > 1 and (pos != sub_first + 1):
397
- # Sub char set closed; decrement depth counter
398
- found -= 1
399
- current.append(t)
400
- else:
401
- current.append(t)
402
- pos += 1
403
- t = next(i)
373
+ self.parse_set(i)
404
374
  except StopIteration as e:
405
- raise PatternError(f"Missing ']', unterminated character set at position {pos}") from e
375
+ raise PatternError(f"Missing ']', unterminated character set at position {start}") from e
376
+
377
+ return [self.text[start:i.index]]
378
+
379
+ def parse_set(self, i: _util.StringIter) -> None:
380
+ """Parse a character set, starting after its opening `[`, through its closing `]`."""
381
+
382
+ self.match(i, '^')
383
+ while True:
384
+ self.parse_set_operand(i)
385
+ if self.version == _regex.V1 and self.peek(i, 2) in _SET_OPS:
386
+ next(i)
387
+ next(i)
388
+ continue
389
+ # The operand only ends at an operator or `]`.
390
+ next(i)
391
+ return
392
+
393
+ def parse_set_operand(self, i: _util.StringIter) -> None:
394
+ """Parse the members of a set (or of an operand in a set operation) up to an operator or `]`."""
395
+
396
+ # The first member is always a member, even if it is `]`.
397
+ self.parse_set_member(i)
398
+ while True:
399
+ c = self.peek(i, 2)
400
+ if not c:
401
+ raise StopIteration
402
+ if c[0] == ']' or (self.version == _regex.V1 and c in _SET_OPS):
403
+ return
404
+ self.parse_set_member(i)
405
+
406
+ def parse_set_member(self, i: _util.StringIter) -> None:
407
+ """Parse a member of a character set, which may be a range."""
408
+
409
+ if not self.parse_set_item(i) or self.peek(i) != '-':
410
+ return
411
+
412
+ after = self.peek(i, 2)
413
+ if self.version == _regex.V1 and after == '--':
414
+ # A set difference, not a range.
415
+ return
416
+ next(i)
417
+ if after == '-]':
418
+ # A literal `-` at the end of the set.
419
+ return
420
+ self.parse_set_item(i)
421
+
422
+ def parse_set_item(self, i: _util.StringIter) -> bool:
423
+ """Parse a single item of a character set, and return whether it is a single character."""
406
424
 
407
- return current
425
+ c = next(i)
426
+ if c == '\\':
427
+ return self.parse_set_escape(i)
428
+ if c == '[':
429
+ if self.parse_posix(i):
430
+ return False
431
+ if self.version == _regex.V1:
432
+ # A nested set.
433
+ self.parse_set(i)
434
+ return False
435
+ return True
436
+
437
+ def parse_set_escape(self, i: _util.StringIter) -> bool:
438
+ """Parse an escape in a character set, and return whether it is a single character."""
439
+
440
+ c = next(i)
441
+ if c in _HEX_ESCAPES:
442
+ self.consume(i, _HEX_DIGITS, _HEX_ESCAPES[c])
443
+ elif c == 'N':
444
+ # A named character, or otherwise a literal `N`.
445
+ index = i.index
446
+ if self.match(i, '{'):
447
+ self.consume(i, _NAMED_CHAR)
448
+ if self.match(i, '}'):
449
+ return True
450
+ i.rewind(i.index - index)
451
+ elif c in ('p', 'P'):
452
+ index = i.index
453
+ if self.match(i, '{'):
454
+ self.match(i, '^')
455
+ self.parse_property_name(i)
456
+ if self.match(i, '}'):
457
+ return False
458
+ # Not a property, so a literal `p` or `P`.
459
+ i.rewind(i.index - index)
460
+ elif self.peek(i) in _SHORT_PROPERTIES:
461
+ next(i)
462
+ return False
463
+ elif c in _CLASS_ESCAPES:
464
+ return False
465
+ elif c in _DIGIT:
466
+ self.consume(i, _OCTAL, 2)
467
+ return True
468
+
469
+ def parse_posix(self, i: _util.StringIter) -> bool:
470
+ """Parse a POSIX class after an opening `[`, or consume nothing if it isn't one."""
471
+
472
+ index = i.index
473
+ if self.match(i, ':'):
474
+ self.match(i, '^')
475
+ self.parse_property_name(i)
476
+ if self.match(i, ':]'):
477
+ return True
478
+ i.rewind(i.index - index)
479
+ return False
480
+
481
+ def parse_property_name(self, i: _util.StringIter) -> None:
482
+ """Parse a property name, which may be qualified with a value: `name`, `name=value`, or `name:value`."""
483
+
484
+ self.consume(i, _PROPERTY_NAME)
485
+ index = i.index
486
+ if self.peek(i) in (':', '='):
487
+ next(i)
488
+ if self.consume(i, _PROPERTY_VALUE).strip():
489
+ return
490
+ # No value, so the name is unqualified.
491
+ i.rewind(i.index - index)
408
492
 
409
493
  def normal(self, t: str, i: _util.StringIter) -> list[str]:
410
494
  """Handle normal chars."""
@@ -445,7 +529,8 @@ class _SearchParser(Generic[AnyStr]):
445
529
  self.verbose = bool(self.re_verbose)
446
530
  self.version = self.re_version if self.re_version else _regex.DEFAULT_VERSION
447
531
  new_pattern = []
448
- i = _util.StringIter(self.process_quotes(search))
532
+ self.text = self.process_quotes(search)
533
+ i = _util.StringIter(self.text)
449
534
  new_pattern = self.main_group(i)
450
535
  return "".join(new_pattern)
451
536
 
@@ -267,8 +267,9 @@ class Bre(_util.Immutable, Generic[AnyStr]):
267
267
  if isinstance(template, ReplaceTemplate):
268
268
  if use_format != template.use_format:
269
269
  raise ValueError("Compiled replace cannot be a format object!")
270
- elif isinstance(template, ReplaceTemplate) or (isinstance(template, (str, bytes)) and self.auto_compile):
271
- return self.compile(template, (FORMAT if use_format and not isinstance(template, ReplaceTemplate) else 0))
270
+ return self.compile(template)
271
+ elif isinstance(template, (str, bytes)) and self.auto_compile:
272
+ return self.compile(template, (FORMAT if use_format else 0))
272
273
  elif isinstance(template, (str, bytes)) and use_format:
273
274
  # Reject an attempt to run format replace when auto-compiling
274
275
  # of template strings has been disabled and we are using a