diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 0d6eae4..65b6023 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -21,6 +21,7 @@ New features: Bug fixes: +- Escape literal regex set operators in gitignore bracket expressions so valid patterns compile when FutureWarning is treated as an error. - Hyperscan backend no longer fails on multibyte UTF-8 characters. - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 5deb32e..880dd3f 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -21,6 +21,8 @@ r'|\\(.)' # POSIX class. r'|\[:(\^?)([^:\]]*):\]' + # Literal characters reserved for future regex set operations. + r'|(\[|&|~|\|)' ), re.DOTALL) """ Matches tokens inside a bracket expression: @@ -32,6 +34,8 @@ - Matches a POSIX bracket character class token such as ``[:alpha:]`` inside a bracket expression. Group 4 captures a leading caret (unsupported by git); group 5 captures the class name. + +- A literal character requiring regex escaping (group 6). """ _BYTES_ENCODING = 'latin1' @@ -102,13 +106,14 @@ def _sub_bracket_token(match: re.Match[str]) -> str: """ Translate a single token matched by :data:`_BRACKET_TOKEN_REGEX`. A token can be a range (groups 1 and 2), a backslash escaped character (group 3), or a - POSIX bracket character class (groups 4 and 5). + POSIX bracket character class (groups 4 and 5), or a literal regex set + operator character (group 6). *match* (:class:`re.Match`) is the match object. Returns the replacement string (:class:`str`). """ - range_start, range_end, escape_char, class_negated, class_name = match.groups() + range_start, range_end, escape_char, class_negated, class_name, literal_char = match.groups() # Character range. A bracket expression cannot match a directory separator. if range_start is not None: @@ -120,6 +125,8 @@ def _sub_bracket_token(match: re.Match[str]) -> str: # Backslash escaped character. if escape_char: return re.escape(escape_char) + elif literal_char: + return re.escape(literal_char) # POSIX bracket character class. class_regex = _POSIX_CLASS_TO_REGEX.get(class_name) diff --git a/tests/test_10_gitignore_bracket_literals.py b/tests/test_10_gitignore_bracket_literals.py new file mode 100644 index 0000000..3b14ca9 --- /dev/null +++ b/tests/test_10_gitignore_bracket_literals.py @@ -0,0 +1,68 @@ +"""Test literal regex set operators in gitignore bracket expressions.""" + +import re +import unittest +import warnings + +from pathspec import GitIgnoreSpec, PathSpec +from pathspec.patterns.gitignore.basic import GitIgnoreBasicPattern +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern +from pathspec.patterns.gitwildmatch import GitWildMatchPattern + +from .util import require_backend + + +CASES = ( + ('[[]draft].md', ('[draft].md',), ('draft.md', 'adraft].md')), + ('[a&&b].pdf', ('a.pdf', '&.pdf', 'b.pdf'), ('c.pdf', 'ab.pdf')), + ('[a||b].pdf', ('a.pdf', '|.pdf', 'b.pdf'), ('c.pdf', 'ab.pdf')), + ('[a~~b].pdf', ('a.pdf', '~.pdf', 'b.pdf'), ('c.pdf', 'ab.pdf')), + ('[![]draft].md', ('adraft].md',), ('[draft].md', 'draft.md')), + ('[!a&&b].pdf', ('c.pdf',), ('a.pdf', '&.pdf', 'b.pdf')), + ('[^a||b].pdf', ('c.pdf',), ('a.pdf', '|.pdf', 'b.pdf')), + ('[!a~~b].pdf', ('c.pdf',), ('a.pdf', '~.pdf', 'b.pdf')), + ('[[:digit:]&&a-c].pdf', ('0.pdf', '&.pdf', 'b.pdf'), ('d.pdf', 'x.pdf')), + (r'[\[\&\|\~].pdf', ('[.pdf', '&.pdf', '|.pdf', '~.pdf'), ('a.pdf',)), + ('[--0].pdf', ('-.pdf', '..pdf', '0.pdf'), ('1.pdf', '/.pdf')), +) + + +class BracketLiteralTest(unittest.TestCase): + + def test_pattern_literals(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern, GitWildMatchPattern): + for line, matches, misses in CASES: + for as_bytes in (False, True): + for errors in (None, 'literal', 'null', 'raise'): + with self.subTest(factory=factory, line=line, as_bytes=as_bytes, errors=errors): + re.purge() + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + warnings.simplefilter('error', FutureWarning) + pattern = factory(line.encode() if as_bytes else line, errors=errors) + for paths, expected in ((matches, True), (misses, False)): + for path in paths: + self.assertEqual( + bool(pattern.match_file(path.encode() if as_bytes else path)), + expected, path, + ) + + def test_spec_literals(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for line, matches, misses in CASES: + for style in ('gitignore', 'gitwildmatch', None): + with self.subTest(line=line, style=style): + re.purge() + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + warnings.simplefilter('error', FutureWarning) + if style is None: + spec = GitIgnoreSpec.from_lines([line], backend=backend) + else: + spec = PathSpec.from_lines(style, [line], backend=backend) + for prefix in ('', 'docs/'): + for paths, expected in ((matches, True), (misses, False)): + for path in paths: + self.assertEqual(spec.match_file(prefix + path), expected, path)