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
41 changes: 35 additions & 6 deletions pathspec/patterns/gitignore/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@
group 2 captures the class name.
"""

_BRACKET_TOKEN_REGEX = re.compile(
_POSIX_CLASS_REGEX.pattern + r'|\\(.)', re.DOTALL,
)
"""
Matches either a POSIX bracket character class token (groups 1 and 2, see
:data:`_POSIX_CLASS_REGEX`) or a backslash escaped character (group 3) inside a
bracket expression.
"""


def _strip_trailing_ws(pattern: str) -> str:
"""
Expand Down Expand Up @@ -101,6 +110,20 @@ class name, matching git's treatment of it as a malformed pattern.
return class_regex


def _translate_bracket_token(match: 're.Match') -> str:
"""
Translate a single token matched by :data:`_BRACKET_TOKEN_REGEX`. A backslash
escaped character becomes that literal character, the same as git's
wildmatch. A POSIX character class is translated by
:func:`_translate_posix_class`.
"""
escaped = match.group(3)
if escaped is not None:
return re.escape(escaped)

return _translate_posix_class(match)


class _GitIgnoreBasePattern(RegexPattern):
"""
.. warning:: This class is not part of the public API. It is subject to
Expand Down Expand Up @@ -262,7 +285,11 @@ def _translate_segment_glob(

# Find closing bracket. Stop once we reach the end or find it.
while j < end and pattern[j] != ']':
if pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':':
if pattern[j] == '\\':
# Skip over an escaped character so an escaped closing bracket
# ("\]") does not end the bracket expression.
j += 2
elif pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':':
# Skip over a POSIX character class token ("[:name:]") so its
# internal closing bracket is not mistaken for the end of the whole
# bracket expression.
Expand Down Expand Up @@ -298,17 +325,19 @@ def _translate_segment_glob(
expr += '^'
i += 1

# Build regex bracket expression. Escape slashes so they are treated
# as literal slashes by regex as defined by POSIX.
body = pattern[i:j].replace('\\', '\\\\')

# Build regex bracket expression. A backslash escapes the next
# character (e.g. "[\]]" matches ']'), the same as outside of a bracket
# expression.
#
# Translate POSIX character classes (e.g. "[:alpha:]") into their
# ASCII regex equivalents. Git's wildmatch supports these, but
# Python's `re` does not, so passing them through verbatim builds a
# broken regex that silently mismatches (and warns about a nested
# set).
try:
body = _POSIX_CLASS_REGEX.sub(_translate_posix_class, body)
body = _BRACKET_TOKEN_REGEX.sub(
_translate_bracket_token, pattern[i:j],
)
except _PosixClassError:
if errors == 'raise':
# EDGE CASE: Git discards patterns with an invalid range notation
Expand Down
20 changes: 20 additions & 0 deletions tests/test_03_gitignore_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,26 @@ def test_13_globstars_match_newlines(self):
with self.subTest(pattern=pattern, path=path):
self.assertIsNotNone(GitIgnoreBasicPattern(pattern).match_file(path))

def test_14_bracket_escape(self):
"""
Test that a backslash inside a bracket expression escapes the next
character.
"""
for raw_pattern, positives, negatives in [
('[a\\-c]', ['a', '-', 'c'], ['b', '\\']),
('[\\]]', [']'], ['\\', '\\]']),
('[a\\]]', ['a', ']'], ['\\', '\\]']),
('[\\!a]', ['!', 'a'], ['\\']),
('[\\\\]', ['\\'], ['a']),
]:
pattern = GitIgnoreBasicPattern(raw_pattern)
for path in positives:
with self.subTest(f"p={raw_pattern!r} match {path!r}"):
self.assertIsNotNone(pattern.match_file(path))
for path in negatives:
with self.subTest(f"p={raw_pattern!r} no-match {path!r}"):
self.assertIsNone(pattern.match_file(path))


class GitIgnoreBasicPatternIssue19Test(TestCase):
"""
Expand Down
26 changes: 26 additions & 0 deletions tests/test_04_gitignore_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,32 @@ def test_12_posix_class_e_invalid(self):
self.assertIs(pattern.include, None)
self.assertIs(pattern.regex, None)

def test_13_bracket_escape(self):
"""
Test that a backslash inside a bracket expression escapes the next
character, matching git's ``check-ignore``.
"""
for raw_pattern, positives, negatives in [
('[a\\-c]', ['a', '-', 'c'], ['b', '\\']),
('[\\]]', [']'], ['\\', '\\]']),
('[\\]a]', [']', 'a'], ['\\', '\\]a']),
('[a\\]]', ['a', ']'], ['\\', '\\]']),
('[!\\]]', ['a', '\\'], [']']),
('[\\!a]', ['!', 'a'], ['\\']),
('[\\^a]', ['^', 'a'], ['\\', 'b']),
('[\\n]', ['n'], ['\\', '\n']),
('[\\\\]', ['\\'], ['a']),
('[\\[:alpha:]]', ['[]', ':]', 'a]'], ['a', 'b', '\\']),
('[[:alpha:]\\]]', ['a', ']'], ['5', '\\']),
]:
pattern = GitIgnoreSpecPattern(raw_pattern)
for path in positives:
with self.subTest(f"p={raw_pattern!r} match {path!r}"):
self.assertTrue(pattern.match_file(path))
for path in negatives:
with self.subTest(f"p={raw_pattern!r} no-match {path!r}"):
self.assertFalse(pattern.match_file(path))


class GitIgnoreSpecPatternIssue19Test(TestCase):
"""
Expand Down
Loading