1"""
2This module provides :class:`GitIgnoreSpecPattern` which implements Git's
3`gitignore`_ patterns, and handles edge-cases where Git's behavior differs from
4what's documented. Git allows including files from excluded directories which
5appears to contradict the documentation. Git discards patterns with invalid
6range notation. This is used by :class:`~pathspec.gitignore.GitIgnoreSpec` to
7fully replicate Git's handling.
8
9.. _`gitignore`: https://git-scm.com/docs/gitignore
10"""
11
12from typing import (
13 Literal,
14 Optional) # Replaced by `X | None` in 3.10.
15
16from pathspec._typing import (
17 AnyStr, # Removed in 3.18.
18 assert_unreachable,
19 override) # Added in 3.12.
20
21from .base import (
22 GitIgnorePatternError,
23 _BYTES_ENCODING,
24 _GitIgnoreBasePattern,
25 _PosixClassError,
26 _RangeNotationError,
27 _TrailingBackslashError,
28 _collapse_star_runs,
29 _strip_trailing_ws)
30
31_DIR_MARK = 'ps_d'
32"""
33The regex group name for the directory marker. This is only used by
34:class:`GitIgnoreSpec`.
35"""
36
37_DIR_MARK_CG = f'(?P<{_DIR_MARK}>/)'
38"""
39This regular expression matches the directory marker.
40"""
41
42_DIR_MARK_OPT = rf'(?:{_DIR_MARK_CG}|\Z)'
43"""
44This regular expression matches the optional directory marker and sub-path.
45"""
46
47_MATCH_ALL = f'^(?s:.+/)?[^/]+{_DIR_MARK_OPT}'
48"""
49This regular expression matches every path. It is the expansion of the patterns
50"*" and "**" (i.e., "**/{any name}"), and it has to capture the directory marker
51like any other pattern so that :class:`.GitIgnoreSpec` can tell a directory
52match from a file match.
53"""
54
55
56class GitIgnoreSpecPattern(_GitIgnoreBasePattern):
57 """
58 The :class:`GitIgnoreSpecPattern` class represents a compiled gitignore
59 pattern with special handling for edge-cases to replicate Git's behavior.
60
61 This is registered under the deprecated name "gitwildmatch" for backward
62 compatibility with v0.12. The registered name will be removed in a future
63 version.
64 """
65
66 # Keep the dict-less class hierarchy.
67 __slots__ = ()
68
69 @staticmethod
70 def __normalize_segments(
71 is_dir_pattern: bool,
72 pattern_segs: list[str],
73 requires_dir_descendant: bool,
74 ) -> tuple[Optional[list[str]], Optional[str]]:
75 """
76 Normalize the pattern segments to make processing easier.
77
78 *is_dir_pattern* (:class:`bool`) is whether the pattern is a directory
79 pattern (i.e., ends with a slash '/').
80
81 *pattern_segs* (:class:`list` of :class:`str`) contains the pattern
82 segments. This may be modified in place.
83
84 *requires_dir_descendant* (:class:`bool`) indicates whether a terminal
85 directory globstar must consume a descendant segment before the directory
86 marker.
87
88 Raises :exc:`ValueError` if the pattern normalizes to nothing.
89
90 Returns a :class:`tuple` containing either:
91
92 - The normalized segments (:class:`list` of :class:`str`; or :data:`None`).
93
94 - The regular expression override (:class:`str` or :data:`None`).
95 """
96 if not pattern_segs[0]:
97 # A pattern beginning with a slash ('/') should match relative to the root
98 # directory. Remove the empty first segment to make the pattern relative
99 # to root.
100 del pattern_segs[0]
101
102 elif len(pattern_segs) == 1 or (len(pattern_segs) == 2 and not pattern_segs[1]):
103 # A single segment pattern with or without a trailing slash ('/') will
104 # match any descendant path. This is equivalent to "**/{pattern}". Prepend
105 # a double-asterisk segment to make the pattern relative to root.
106 if pattern_segs[0] != '**':
107 pattern_segs.insert(0, '**')
108
109 else:
110 # A pattern without a beginning slash ('/') but contains at least one
111 # prepended directory (e.g., "dir/{pattern}") should match relative to the
112 # root directory. No segment modification is needed.
113 pass
114
115 if not pattern_segs:
116 # After normalization, we end up with no pattern at all. This must be
117 # because the pattern is invalid.
118 raise ValueError("Pattern normalized to nothing.")
119
120 if not pattern_segs[-1]:
121 # A pattern ending with a slash ('/') will match all descendant paths if
122 # it is a directory but not if it is a regular file. This is equivalent to
123 # "{pattern}/**". Set the empty last segment to a double-asterisk to
124 # include all descendants.
125 pattern_segs[-1] = '**'
126
127 # EDGE CASE: Collapse duplicate double-asterisk sequences (i.e., '**/**').
128 # Iterate over the segments in reverse order and remove the duplicate double
129 # asterisks as we go.
130 for i in range(len(pattern_segs) - 1, 0, -1):
131 prev = pattern_segs[i-1]
132 seg = pattern_segs[i]
133 if prev == '**' and seg == '**':
134 del pattern_segs[i]
135
136 seg_count = len(pattern_segs)
137 if seg_count == 1 and pattern_segs[0] == '**':
138 if is_dir_pattern:
139 # The pattern "**/" will be normalized to "**", but it should match
140 # everything except for files in the root. Special case this pattern.
141 return (None, _DIR_MARK_CG)
142 else:
143 # The pattern "**" will match every path. Special case this pattern.
144 return (None, _MATCH_ALL)
145
146 elif (
147 seg_count == 2
148 and pattern_segs[0] == '**'
149 and pattern_segs[1] == '*'
150 ):
151 # The pattern "*" will be normalized to "**/*" and will match every
152 # path. Special case this pattern for efficiency.
153 return (None, _MATCH_ALL)
154
155 elif (
156 seg_count == 3
157 and pattern_segs[0] == '**'
158 and pattern_segs[1] == '*'
159 and pattern_segs[2] == '**'
160 and not requires_dir_descendant
161 ):
162 # The pattern "*/" will be normalized to "**/*/**" which will match every
163 # file not in the root directory. Special case this pattern for
164 # efficiency.
165 if is_dir_pattern:
166 return (None, _DIR_MARK_CG)
167 else:
168 return (None, '/')
169
170 # No regular expression override, return modified pattern segments.
171 return (pattern_segs, None)
172
173 @override
174 @classmethod
175 def pattern_to_regex(
176 cls,
177 pattern: AnyStr,
178 *,
179 errors: Optional[Literal['literal', 'null', 'raise']] = None,
180 ) -> tuple[Optional[AnyStr], Optional[bool]]:
181 """
182 Convert the pattern into a regular expression.
183
184 *pattern* (:class:`str` or :class:`bytes`) is the pattern to convert into a
185 regular expression.
186
187 *errors* (:class:`str` :data:`None`) is how to handle invalid notation in
188 the pattern. Default is :data:`None` for ``'null'`` because that is the
189 behavior of Git.
190
191 - ``'literal'``: Most invalid notation will be treated as a literal
192 string. In cases where the gitignore documentation clearly states the
193 pattern should never match, a null-operation will be returned.
194
195 - ``'null'``: Invalid notation will result in a null-operation.
196
197 - ``'raise'``: Invalid notation will raise a :exc:`.GitIgnorePatternError`.
198
199 Raises :exc:`.GitIgnorePatternError` if the pattern fails to process,
200 regardless of the value of *errors*.
201
202 Returns a :class:`tuple` containing:
203
204 - *pattern* (:class:`str`, :class:`bytes` or :data:`None`) is the uncompiled
205 regular expression.
206
207 - *include* (:class:`bool` or :data:`None`) is whether matched files should
208 be included (:data:`True`), excluded (:data:`False`), or is a
209 null-operation (:data:`None`).
210 """
211 if isinstance(pattern, str):
212 pattern_str = pattern
213 return_type = str
214 elif isinstance(pattern, bytes):
215 pattern_str = pattern.decode(_BYTES_ENCODING)
216 return_type = bytes
217 else:
218 raise TypeError(f"{pattern=!r} is not a unicode or byte string.")
219
220 original_pattern = pattern_str
221 del pattern
222
223 if errors is None:
224 errors = 'null'
225 elif errors not in ('literal', 'null', 'raise'):
226 raise ValueError(f"{errors=!r} is not a valid value.")
227
228 seg_errors: Literal['literal', 'raise']
229 if errors == 'null':
230 seg_errors = 'raise'
231 elif errors in ('literal', 'raise'):
232 seg_errors = errors
233 else:
234 assert_unreachable(f"Failed to map {errors=!r} to seg_errors.")
235
236 # Strip trailing whitespace.
237 pattern_str = _strip_trailing_ws(pattern_str)
238
239 regex: Optional[str]
240 include: Optional[bool]
241
242 if pattern_str.startswith('#'):
243 # A pattern starting with a hash ('#') serves as a comment (neither
244 # includes nor excludes files). Escape the hash with a backslash to match
245 # a literal hash (i.e., '\#').
246 return (None, None)
247
248 elif pattern_str == '/':
249 # EDGE CASE: According to `git check-ignore` (v2.4.1), a single '/' does
250 # not match any file.
251 return (None, None)
252
253 if pattern_str.startswith('!'):
254 # A pattern starting with an exclamation mark ('!') negates the pattern
255 # (exclude instead of include). Escape the exclamation mark with a
256 # backslash to match a literal exclamation mark (i.e., '\!').
257 include = False
258 # Remove leading exclamation mark.
259 pattern_str = pattern_str[1:]
260 else:
261 include = True
262
263 if not pattern_str:
264 # A blank pattern is a null-operation (neither includes nor excludes
265 # files).
266 return (None, None)
267
268 # Split pattern into segments.
269 orig_segs = _collapse_star_runs(pattern_str.split('/'))
270
271 # Check whether the pattern is specifically a directory pattern before
272 # normalization.
273 is_dir_pattern = not orig_segs[-1]
274 # A terminal directory globstar with a concrete prefix must consume a
275 # descendant segment before the directory marker. Keep this semantic bit
276 # separate from normalized segments, which collapse adjacent ``**``.
277 requires_dir_descendant = (
278 is_dir_pattern
279 and len(orig_segs) > 2
280 and orig_segs[-2] == '**'
281 and any(seg not in ('', '**') for seg in orig_segs[:-2])
282 )
283
284 # Normalize pattern to make processing easier.
285 try:
286 pattern_segs, override_regex = cls.__normalize_segments(
287 is_dir_pattern, orig_segs, requires_dir_descendant,
288 )
289 except ValueError as e:
290 raise GitIgnorePatternError((
291 f"Invalid git pattern: {original_pattern!r}"
292 )) from e # GitIgnorePatternError
293
294 if override_regex is not None:
295 # Use regex override.
296 regex = override_regex
297
298 elif pattern_segs is not None:
299 # Build regular expression from pattern.
300 try:
301 regex_parts = cls.__translate_segments(
302 seg_errors, is_dir_pattern, pattern_segs, requires_dir_descendant,
303 )
304 except (_PosixClassError, _RangeNotationError) as e:
305 if errors == 'raise':
306 raise GitIgnorePatternError((
307 f"Invalid git pattern: {original_pattern!r}"
308 )) from e # GitIgnorePatternError
309 else:
310 return (None, None)
311 except _TrailingBackslashError as e:
312 # EDGE CASE: The gitignore docs say a trailing backlash is invalid and
313 # never matches.
314 if errors == 'raise':
315 raise GitIgnorePatternError((
316 f"Invalid git pattern: {original_pattern!r}"
317 )) from e # GitIgnorePatternError
318 else:
319 return (None, None)
320
321 regex = ''.join(regex_parts)
322
323 else:
324 assert_unreachable((
325 f"{override_regex=} and {pattern_segs=} cannot both be null."
326 )) # assert_unreachable
327
328 # Encode regex if needed.
329 out_regex: AnyStr
330 if regex is not None and return_type is bytes:
331 regex_bytes = regex.encode(_BYTES_ENCODING)
332 out_regex = regex_bytes # type: ignore[assignment]
333 else:
334 out_regex = regex # type: ignore[assignment]
335
336 return (out_regex, include)
337
338 @classmethod
339 def __translate_segments(
340 cls,
341 errors: Literal['literal', 'raise'],
342 is_dir_pattern: bool,
343 pattern_segs: list[str],
344 requires_dir_descendant: bool,
345 ) -> list[str]:
346 """
347 Translate the pattern segments to regular expressions.
348
349 *errors* (:class:`str`) is how to handle invalid pattern notation in the
350 pattern:
351
352 - ``'literal'``: Invalid notation will be treated as a literal string.
353
354 - ``'raise'``: Invalid notation will raise an exception.
355
356 *is_dir_pattern* (:class:`bool`) is whether the pattern is a directory
357 pattern (i.e., ends with a slash '/').
358
359 *pattern_segs* (:class:`list` of :class:`str`) contains the pattern
360 segments.
361
362 *requires_dir_descendant* (:class:`bool`) indicates whether a terminal
363 directory globstar must consume a descendant segment before the directory
364 marker.
365
366 Raises :exc:`._PosixClassError` when an invalid POXIS class is found and
367 *errors* is ``'literal'``.
368
369 Raises :exc:`._RangeNotationError` when an invalid range notation is found
370 and *errors* is ``'literal'``.
371
372 Raises :exc:`._TrailingBackslashError` when a trailing backslash is found at
373 the end of the pattern, regardless of the value of *errors*.
374
375 Returns the regular expression parts (:class:`list` of :class:`str`).
376 """
377 # Build regular expression from pattern.
378 out_parts = []
379 need_slash = False
380 end = len(pattern_segs) - 1
381 for i, seg in enumerate(pattern_segs):
382 if seg == '**':
383 if i == 0:
384 # A normalized pattern beginning with double-asterisks ('**') will
385 # match any leading path segments.
386 out_parts.append('^(?s:.+/)?')
387
388 elif i < end:
389 # A pattern with inner double-asterisks ('**') will match multiple (or
390 # zero) inner path segments.
391 out_parts.append('(?s:/.+)?')
392 need_slash = True
393
394 else:
395 assert i == end, (i, end)
396 # A normalized pattern ending with double-asterisks ('**') will match
397 # nonempty trailing path segments, not the parent directory itself.
398 if is_dir_pattern:
399 if requires_dir_descendant:
400 out_parts.append('(?s:/.+)')
401 out_parts.append(_DIR_MARK_CG)
402 else:
403 out_parts.append('/[^/]')
404
405 else:
406 # Match path segment.
407 if i == 0:
408 # Anchor to root directory.
409 out_parts.append('^')
410
411 if need_slash:
412 out_parts.append('/')
413
414 if seg == '*':
415 # Match whole path segment.
416 out_parts.append('[^/]+')
417
418 else:
419 # Match segment glob pattern.
420 # - EDGE CASE: Git discards patterns with invalid range notation.
421 out_parts.append(cls._translate_segment_glob(seg, errors))
422
423 if i == end:
424 # A pattern ending without a slash ('/') will match a file or a
425 # directory (with paths underneath it). E.g., "foo" matches "foo",
426 # "foo/bar", "foo/bar/baz", etc.
427 out_parts.append(_DIR_MARK_OPT)
428
429 need_slash = True
430
431 return out_parts