Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES_1.in.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions pathspec/patterns/gitignore/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions tests/test_10_gitignore_bracket_literals.py
Original file line number Diff line number Diff line change
@@ -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)
Loading