1"""
2This module provides common classes for the gitignore patterns.
3"""
4
5import re
6
7from typing import (
8 Literal)
9
10from pathspec.pattern import (
11 RegexPattern)
12from pathspec._typing import (
13 AnyStr, # Removed in 3.18.
14 assert_unreachable)
15
16_BYTES_ENCODING = 'latin1'
17"""
18The encoding to use when parsing a byte string pattern.
19"""
20
21_POSIX_CLASS_TO_REGEX = {
22 # Git's wildmatch implements POSIX bracket character classes using its own
23 # ASCII (locale-independent) ``is*`` functions, so each class maps to an
24 # explicit ASCII set. These are NOT the Unicode-aware equivalents (``\w``,
25 # ``\d``, ``\s``); using those would over-match non-ASCII characters that
26 # git never matches.
27 'alnum': '0-9A-Za-z',
28 'alpha': 'A-Za-z',
29 'blank': '\\t ',
30 'cntrl': '\\x00-\\x1f\\x7f',
31 'digit': '0-9',
32 'graph': '\\x21-\\x7e',
33 'lower': 'a-z',
34 'print': '\\x20-\\x7e',
35 'punct': '!-/:-@\\[-`{-~',
36 'space': '\\t\\n\\r ',
37 'upper': 'A-Z',
38 'xdigit': '0-9A-Fa-f',
39}
40"""
41Maps each POSIX bracket character class name to the ASCII regex range that
42reproduces git's wildmatch behavior.
43"""
44
45_POSIX_CLASS_REGEX = re.compile(r'\[:(\^?)([^:\]]*):\]')
46"""
47Matches a POSIX bracket character class token such as ``[:alpha:]`` inside a
48bracket expression. Group 1 captures a leading caret (unsupported by git);
49group 2 captures the class name.
50"""
51
52
53class _InvalidPosixClass(Exception):
54 """
55 Raised internally when a bracket expression contains an unknown or negated
56 POSIX character class name. Git treats such a pattern as malformed.
57 """
58 pass
59
60
61def _translate_posix_class(match: 're.Match') -> str:
62 """
63 Translate a single POSIX character class token to its ASCII regex range.
64 Raises :class:`_InvalidPosixClass` for a negated (``[:^name:]``) or unknown
65 class name, matching git's treatment of it as a malformed pattern.
66 """
67 negated, name = match.group(1), match.group(2)
68 class_regex = _POSIX_CLASS_TO_REGEX.get(name)
69 if negated or class_regex is None:
70 raise _InvalidPosixClass()
71 return class_regex
72
73
74class _GitIgnoreBasePattern(RegexPattern):
75 """
76 .. warning:: This class is not part of the public API. It is subject to
77 change.
78
79 The :class:`_GitIgnoreBasePattern` class is the base implementation for a
80 compiled gitignore pattern.
81 """
82
83 # Keep the dict-less class hierarchy.
84 __slots__ = ()
85
86 @staticmethod
87 def escape(s: AnyStr) -> AnyStr:
88 """
89 Escape special characters in the given string.
90
91 *s* (:class:`str` or :class:`bytes`) a filename or a string that you want to
92 escape, usually before adding it to a ".gitignore".
93
94 Returns the escaped string (:class:`str` or :class:`bytes`).
95 """
96 if isinstance(s, str):
97 return_type = str
98 string = s
99 elif isinstance(s, bytes):
100 return_type = bytes
101 string = s.decode(_BYTES_ENCODING)
102 else:
103 raise TypeError(f"s:{s!r} is not a unicode or byte string.")
104
105 # Reference: https://git-scm.com/docs/gitignore#_pattern_format
106 out_string = ''.join((f"\\{x}" if x in '\\[]!*#?' else x) for x in string)
107
108 # EDGE CASE: Git strips trailing spaces from a pattern unless they are
109 # escaped with a backslash. Escape them so an escaped filename that ends
110 # with a space still matches that file.
111 stripped = out_string.rstrip(' ')
112 trailing = len(out_string) - len(stripped)
113 if trailing:
114 out_string = stripped + '\\ ' * trailing
115
116 if return_type is bytes:
117 out_bytes = out_string.encode(_BYTES_ENCODING)
118 return out_bytes # type: ignore[return-value]
119 else:
120 return out_string # type: ignore[return-value]
121
122 @staticmethod
123 def _translate_segment_glob(
124 pattern: str,
125 range_error: Literal['literal', 'raise'],
126 ) -> str:
127 """
128 Translates the glob pattern to a regular expression. This is used in the
129 constructor to translate a path segment glob pattern to its corresponding
130 regular expression.
131
132 *pattern* (:class:`str`) is the glob pattern.
133
134 *range_error* (:class:`int`) is how to handle invalid range notation in the
135 pattern:
136
137 - :data:`"literal"`: Invalid notation will be treated as a literal string.
138
139 - :data:`"raise"`: Invalid notation will cause a :class:`_RangeError` to be
140 raised.
141
142 Returns the regular expression (:class:`str`).
143 """
144 # NOTE: This is derived from `fnmatch.translate()` and is similar to the
145 # POSIX function `fnmatch()` with the `FNM_PATHNAME` flag set.
146
147 escape = False
148 regex = ''
149 i, end = 0, len(pattern)
150 while i < end:
151 # Get next character.
152 char = pattern[i]
153 i += 1
154
155 if escape:
156 # Escape the character.
157 escape = False
158 regex += re.escape(char)
159
160 elif char == '\\':
161 # Escape character, escape next character.
162 escape = True
163
164 elif char == '*':
165 # Multi-character wildcard. Match any string (except slashes), including
166 # an empty string.
167 regex += '[^/]*'
168
169 elif char == '?':
170 # Single-character wildcard. Match any single character (except a
171 # slash).
172 regex += '[^/]'
173
174 elif char == '[':
175 # Bracket expression (range notation) wildcard. Except for the beginning
176 # exclamation mark, the whole bracket expression can be used directly as
177 # regex, but we have to find where the expression ends.
178 # - "[][!]" matches ']', '[' and '!'.
179 # - "[]-]" matches ']' and '-'.
180 # - "[!]a-]" matches any character except ']', 'a' and '-'.
181 bracket_start = i - 1
182 j = i
183
184 # Pass bracket expression negation.
185 if j < end and (pattern[j] == '!' or pattern[j] == '^'):
186 j += 1
187
188 # Pass first closing bracket if it is at the beginning of the
189 # expression.
190 if j < end and pattern[j] == ']':
191 j += 1
192
193 # Find closing bracket. Stop once we reach the end or find it.
194 while j < end and pattern[j] != ']':
195 if pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':':
196 # Skip over a POSIX character class token ("[:name:]") so its
197 # internal closing bracket is not mistaken for the end of the
198 # whole bracket expression.
199 close = pattern.find(':]', j + 2)
200 if close == -1:
201 j = end
202 break
203 j = close + 2
204 else:
205 j += 1
206
207 if j < end:
208 # Found end of bracket expression. Increment j to be one past the
209 # closing bracket:
210 #
211 # [...]
212 # ^ ^
213 # i j
214 #
215 j += 1
216 expr = '['
217
218 if pattern[i] == '!':
219 # Bracket expression needs to be negated.
220 expr += '^'
221 i += 1
222 elif pattern[i] == '^':
223 # POSIX declares that the regex bracket expression negation "[^...]"
224 # is undefined in a glob pattern. Python's `fnmatch.translate()`
225 # escapes the caret ('^') as a literal. Git supports the using a
226 # caret for negation. Maintain consistency with Git because that is
227 # the expected behavior.
228 expr += '^'
229 i += 1
230
231 # Build regex bracket expression. Escape slashes so they are treated
232 # as literal slashes by regex as defined by POSIX.
233 body = pattern[i:j].replace('\\', '\\\\')
234
235 # Translate POSIX character classes (e.g. "[:alpha:]") into their
236 # ASCII regex equivalents. Git's wildmatch supports these but
237 # Python's `re` does not, so passing them through verbatim builds a
238 # broken regex that silently mismatches (and warns about a nested
239 # set).
240 try:
241 body = _POSIX_CLASS_REGEX.sub(_translate_posix_class, body)
242 except _InvalidPosixClass:
243 # Git treats an unknown or negated class name as a malformed
244 # pattern that matches nothing.
245 if range_error == 'raise':
246 raise _RangeError((
247 f"Invalid character class found in pattern={pattern!r}."
248 ))
249 else:
250 # Treat the whole bracket expression as a literal.
251 regex += re.escape(pattern[bracket_start:j])
252 i = j
253 continue
254
255 expr += body
256
257 if range_error == 'raise':
258 try:
259 re.compile(expr)
260 except re.error as e:
261 raise _RangeError((
262 f"Invalid range notation={pattern[i:j]!r} found in "
263 f"pattern={pattern!r}."
264 )) from e
265
266 # Add regex bracket expression to regex result.
267 regex += expr
268
269 # Set i to one past the closing bracket.
270 i = j
271
272 else:
273 # Failed to find closing bracket.
274 if range_error == 'literal':
275 # Treat opening bracket as a bracket literal instead of as an
276 # expression.
277 regex += '\\['
278 elif range_error == 'raise':
279 # Treat invalid range notation as an error.
280 raise _RangeError((
281 f"Invalid range notation={pattern[i:j]!r} found in pattern="
282 f"{pattern!r}."
283 ))
284 else:
285 assert_unreachable(f"{range_error=!r} is invalid.")
286
287 else:
288 # Regular character, escape it for regex.
289 regex += re.escape(char)
290
291 if escape:
292 raise ValueError((
293 f"Escape character found with no next character to escape: {pattern!r}"
294 )) # ValueError
295
296 return regex
297
298
299class GitIgnorePatternError(ValueError):
300 """
301 The :class:`GitIgnorePatternError` class indicates an invalid gitignore
302 pattern.
303 """
304 pass
305
306
307class _RangeError(GitIgnorePatternError):
308 """
309 The :class:`_RangeError` class indicates an invalid range notation was found
310 in a gitignore pattern.
311 """
312 pass