diff --git a/README-dist.rst b/README-dist.rst index cb0ee40..f567c38 100644 --- a/README-dist.rst +++ b/README-dist.rst @@ -127,6 +127,10 @@ accepts ``(?u)``: both match ``\w``, ``\d``, ``\s``, and ``\b`` as ASCII regardless, which is the same difference the native backends already have for the patterns they generate themselves. +Precompiled expressions from other regex libraries keep their original engine +when using "best" or "simple". Explicit native backends reject these expressions +because their compile options cannot be preserved when rebuilding the pattern. + See `benchmarks_backends.md`_ for comparisons between native Python regular expressions and the optional backends. diff --git a/README.rst b/README.rst index eae1536..9b81118 100644 --- a/README.rst +++ b/README.rst @@ -122,6 +122,10 @@ backends translate its compile flags to the global inline flags ``(?ims)``. The flags are only supported by the "simple" backend. The ``re.UNICODE`` flag is ignored. +Precompiled expressions from other regex libraries keep their original engine +when using "best" or "simple". Explicit native backends reject these expressions +because their compile options cannot be preserved when rebuilding the pattern. + See `benchmarks_backends.md`_ for comparisons between native Python regular expressions and the optional backends. diff --git a/pathspec/_backends/_utils.py b/pathspec/_backends/_utils.py index 4f13b73..2ee4584 100644 --- a/pathspec/_backends/_utils.py +++ b/pathspec/_backends/_utils.py @@ -138,7 +138,7 @@ def has_unsupported_flags(patterns: Iterable[Pattern]) -> bool: if not isinstance(regex, re.Pattern): # The pattern was not compiled by Python's *re* module. Its flags are # unknown, and only the simple backend can use it faithfully. - continue + return True if not supports_regex_flags(regex.flags): return True diff --git a/tests/test_08_regex_flags.py b/tests/test_08_regex_flags.py index 9064962..494b662 100644 --- a/tests/test_08_regex_flags.py +++ b/tests/test_08_regex_flags.py @@ -236,6 +236,72 @@ def test_gitignore_directory_precedence(self): plain_result = PathSpec(patterns, backend=backend).check_file('LOGS/KEEP') self.assertEqual((plain_result.include, plain_result.index), (False, 1)) + def test_foreign_regex_falls_back(self): + """ + Foreign compiled expressions keep the behavior of their original engine. + """ + class ForeignRegex: + pattern = 'secret' + + def match(self, file): + return re.match(self.pattern, file, re.IGNORECASE) + + def search(self, file): + return re.search(self.pattern, file, re.IGNORECASE) + + for spec_class in (PathSpec, GitIgnoreSpec): + for backend in (None, 'best', 'simple'): + with self.subTest(spec_class=spec_class, backend=backend): + patterns = [RegexPattern(ForeignRegex(), include=True)] + spec = spec_class(patterns, backend=backend) + self.assertTrue(spec.match_file('SECRET')) + self.assertFalse(spec.match_file('public')) + self.assertTrue(type(spec._backend).__name__.startswith('Simple')) + + for backend in ('re2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for spec_class in (PathSpec, GitIgnoreSpec): + with self.subTest(spec_class=spec_class): + with self.assertRaisesRegex(ValueError, "backend='simple'"): + spec_class([RegexPattern(ForeignRegex(), include=True)], backend=backend) + + def test_foreign_re2_options(self): + """ + Even an RE2 expression cannot be rebuilt without preserving its options. + """ + require_backend('re2') + import re2 + + options = re2.Options() + options.case_sensitive = False + pattern = RegexPattern(re2.compile('secret', options=options), include=True) + for spec_class in (PathSpec, GitIgnoreSpec): + with self.subTest(spec_class=spec_class): + self.assertTrue(spec_class([pattern]).match_file('SECRET')) + + def test_inactive_foreign_regex_stays_native(self): + """ + A no-op foreign expression must not demote other active patterns. + """ + class ForeignRegex: + def match(self, file): + raise AssertionError('inactive expression must not be used') + + patterns = [ + RegexPattern(ForeignRegex(), include=None), + RegexPattern(re.compile('secret'), include=True), + ] + self.assertFalse(has_unsupported_flags(patterns)) + for backend in ('re2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for spec_class in (PathSpec, GitIgnoreSpec): + with self.subTest(spec_class=spec_class): + spec = spec_class(patterns, backend=backend) + result = spec.check_file('secret') + self.assertEqual((result.include, result.index), (True, 1)) + def test_translate_regex_flags(self): """ Only the flags a native engine understands are prepended. @@ -306,7 +372,7 @@ class ForeignPattern(object): def test_has_unsupported_flags(self): """ - Null patterns and foreign patterns are not reported as unsupported. + Null patterns do not require preserving compile flags. """ cases = [ ([], False),