diff --git a/CHANGES.rst b/CHANGES.rst index f47bc6e..5bffc57 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,10 +12,12 @@ Major changes: Bug fixes: - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. +- `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. .. _`Issue #116`: https://github.com/cpburnz/python-pathspec/issues/116 .. _`Pull #123`: https://github.com/cpburnz/python-pathspec/pull/123 +.. _`Pull #128`: https://github.com/cpburnz/python-pathspec/pull/128 1.1.1 (2026-04-26) diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 5c0a4d9..7373a4e 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -18,6 +18,58 @@ The encoding to use when parsing a byte string pattern. """ +_POSIX_CLASS_TO_REGEX = { + # Git's wildmatch implements POSIX bracket character classes using its own + # ASCII (locale-independent) ``is*`` functions, so each class maps to an + # explicit ASCII set. These are NOT the Unicode-aware equivalents (``\w``, + # ``\d``, ``\s``); using those would over-match non-ASCII characters that + # git never matches. + 'alnum': '0-9A-Za-z', + 'alpha': 'A-Za-z', + 'blank': '\\t ', + 'cntrl': '\\x00-\\x1f\\x7f', + 'digit': '0-9', + 'graph': '\\x21-\\x7e', + 'lower': 'a-z', + 'print': '\\x20-\\x7e', + 'punct': '!-/:-@\\[-`{-~', + 'space': '\\t\\n\\r ', + 'upper': 'A-Z', + 'xdigit': '0-9A-Fa-f', +} +""" +Maps each POSIX bracket character class name to the ASCII regex range that +reproduces git's wildmatch behavior. +""" + +_POSIX_CLASS_REGEX = re.compile(r'\[:(\^?)([^:\]]*):\]') +""" +Matches a POSIX bracket character class token such as ``[:alpha:]`` inside a +bracket expression. Group 1 captures a leading caret (unsupported by git); +group 2 captures the class name. +""" + + +class _InvalidPosixClass(Exception): + """ + Raised internally when a bracket expression contains an unknown or negated + POSIX character class name. Git treats such a pattern as malformed. + """ + pass + + +def _translate_posix_class(match: 're.Match') -> str: + """ + Translate a single POSIX character class token to its ASCII regex range. + Raises :class:`_InvalidPosixClass` for a negated (``[:^name:]``) or unknown + class name, matching git's treatment of it as a malformed pattern. + """ + negated, name = match.group(1), match.group(2) + class_regex = _POSIX_CLASS_TO_REGEX.get(name) + if negated or class_regex is None: + raise _InvalidPosixClass() + return class_regex + class _GitIgnoreBasePattern(RegexPattern): """ @@ -118,6 +170,7 @@ def _translate_segment_glob( # - "[][!]" matches ']', '[' and '!'. # - "[]-]" matches ']' and '-'. # - "[!]a-]" matches any character except ']', 'a' and '-'. + bracket_start = i - 1 j = i # Pass bracket expression negation. @@ -131,7 +184,17 @@ def _translate_segment_glob( # Find closing bracket. Stop once we reach the end or find it. while j < end and pattern[j] != ']': - j += 1 + if 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. + close = pattern.find(':]', j + 2) + if close == -1: + j = end + break + j = close + 2 + else: + j += 1 if j < end: # Found end of bracket expression. Increment j to be one past the @@ -159,7 +222,29 @@ def _translate_segment_glob( # Build regex bracket expression. Escape slashes so they are treated # as literal slashes by regex as defined by POSIX. - expr += pattern[i:j].replace('\\', '\\\\') + body = pattern[i:j].replace('\\', '\\\\') + + # 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) + except _InvalidPosixClass: + # Git treats an unknown or negated class name as a malformed + # pattern that matches nothing. + if range_error == 'raise': + raise _RangeError(( + f"Invalid character class found in pattern={pattern!r}." + )) + else: + # Treat the whole bracket expression as a literal. + regex += re.escape(pattern[bracket_start:j]) + i = j + continue + + expr += body if range_error == 'raise': try: diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index 4b525f0..4f1cdc4 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -969,3 +969,114 @@ def test_15_issue_93_c_3_unclosed(self): pattern = GitIgnoreSpecPattern(raw_pattern) self.assertIs(pattern.include, None) self.assertIs(pattern.regex, None) + + def test_16_posix_class_a_regex(self): + """ + Test that each POSIX bracket character class is translated to the ASCII + range that git's wildmatch uses. Git implements these classes with its + own locale-independent ASCII functions, so they are NOT the Unicode-aware + equivalents (``\\w``, ``\\d``, ``\\s``). + """ + for raw_pattern, expr in [ + ('[[:alnum:]]', '[0-9A-Za-z]'), + ('[[:alpha:]]', '[A-Za-z]'), + ('[[:blank:]]', '[\\t ]'), + ('[[:cntrl:]]', '[\\x00-\\x1f\\x7f]'), + ('[[:digit:]]', '[0-9]'), + ('[[:graph:]]', '[\\x21-\\x7e]'), + ('[[:lower:]]', '[a-z]'), + ('[[:print:]]', '[\\x20-\\x7e]'), + ('[[:punct:]]', '[!-/:-@\\[-`{-~]'), + ('[[:space:]]', '[\\t\\n\\r ]'), + ('[[:upper:]]', '[A-Z]'), + ('[[:xdigit:]]', '[0-9A-Fa-f]'), + ]: + with self.subTest(f"p={raw_pattern!r}"): + pattern = GitIgnoreSpecPattern(raw_pattern) + self.assertIs(pattern.include, True) + self.assertEqual( + pattern.regex.pattern, f'^(?:.+/)?{expr}{_DIR_MARK_OPT}', + ) + + def test_16_posix_class_b_match(self): + """ + Test that each POSIX bracket character class matches the same characters + as git (spot checks matching git's ``check-ignore``). + """ + matches = { + '[[:alnum:]]': (['a', 'Z', '5'], ['_', '-', '.']), + '[[:alpha:]]': (['a', 'Z'], ['5', '_']), + '[[:blank:]]': ([' ', '\t'], ['\n', 'a']), + '[[:digit:]]': (['0', '9'], ['a', '/']), + '[[:graph:]]': (['a', '!', '~'], [' ']), + '[[:lower:]]': (['a', 'z'], ['A', '5']), + '[[:print:]]': (['a', ' ', '~'], ['\t']), + '[[:punct:]]': (['!', '.', '~', '@'], ['a', '5', ' ']), + '[[:space:]]': ([' ', '\t', '\n', '\r'], ['a', '\x0b', '\x0c']), + '[[:upper:]]': (['A', 'Z'], ['a', '5']), + '[[:xdigit:]]': (['0', '9', 'a', 'F'], ['g', 'G', '_']), + } + for raw_pattern, (positives, negatives) in matches.items(): + 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)) + + def test_16_posix_class_c_composed(self): + """ + Test that POSIX classes compose with other bracket members, ranges, and + bracket negation, as git allows. + """ + for raw_pattern, positives, negatives in [ + ('[[:digit:]a-f_]', ['5', 'c', '_'], ['g', 'z']), + ('[[:alpha:]0-9]', ['a', 'Z', '5'], ['_', '.']), + ('[[:upper:][:digit:]]', ['A', '5'], ['a', '_']), + ('[![:digit:]]', ['a', '_'], ['5']), + ('*.[[:alpha:]]', ['f.c'], ['f.5']), + ('[[:alnum:]_].py', ['_.py', 'a.py'], ['-.py']), + ]: + 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)) + + def test_16_posix_class_d_ascii_only(self): + """ + Test that the classes stay ASCII-only, matching git rather than Python's + Unicode-aware regex. Non-ASCII digits/letters/spaces must NOT match. + """ + for raw_pattern, path in [ + ('[[:digit:]]', '٠'), # Arabic-Indic digit zero. + ('[[:digit:]]', '²'), # Superscript two. + ('[[:alpha:]]', 'é'), # Latin small e with acute. + ('[[:alpha:]]', 'Ω'), # Greek capital omega. + ('[[:upper:]]', 'À'), # Latin capital A with grave. + ('[[:alnum:]]', 'é'), # Latin small e with acute. + ('[[:space:]]', '\xa0'), # No-break space. + ]: + with self.subTest(f"p={raw_pattern!r} no-match U+{ord(path):04X}"): + pattern = GitIgnoreSpecPattern(raw_pattern) + self.assertFalse(pattern.match_file(path)) + + def test_16_posix_class_e_invalid(self): + """ + Test that an unknown or negated POSIX class name is discarded, matching + git's treatment of it as a malformed pattern. + """ + for raw_pattern in [ + '[[:bogus:]]', + '[[:Alpha:]]', + '[[:^alpha:]]', + '[[::]]', + 'a[[:nope:]]', + ]: + with self.subTest(f"p={raw_pattern!r}"): + pattern = GitIgnoreSpecPattern(raw_pattern) + self.assertIs(pattern.include, None) + self.assertIs(pattern.regex, None)