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 Optional) # Replaced by `X | None` in 3.10.
14
15from pathspec._typing import (
16 AnyStr, # Removed in 3.18.
17 assert_unreachable,
18 override) # Added in 3.12.
19
20from .base import (
21 GitIgnorePatternError,
22 _BYTES_ENCODING,
23 _GitIgnoreBasePattern,
24 _RangeError)
25
26_DIR_MARK = 'ps_d'
27"""
28The regex group name for the directory marker. This is only used by
29:class:`GitIgnoreSpec`.
30"""
31
32_DIR_MARK_CG = f'(?P<{_DIR_MARK}>/)'
33"""
34This regular expression matches the directory marker.
35"""
36
37_DIR_MARK_OPT = f'(?:{_DIR_MARK_CG}|$)'
38"""
39This regular expression matches the optional directory marker and sub-path.
40"""
41
42_MATCH_ALL = f'^(?s:.+/)?[^/]+{_DIR_MARK_OPT}'
43"""
44This regular expression matches every path. It is the expansion of the patterns
45"*" and "**" (i.e., "**/{any name}"), and it has to capture the directory marker
46like any other pattern so that :class:`.GitIgnoreSpec` can tell a directory
47match from a file match.
48"""
49
50
51class GitIgnoreSpecPattern(_GitIgnoreBasePattern):
52 """
53 The :class:`GitIgnoreSpecPattern` class represents a compiled gitignore
54 pattern with special handling for edge-cases to replicate Git's behavior.
55
56 This is registered under the deprecated name "gitwildmatch" for backward
57 compatibility with v0.12. The registered name will be removed in a future
58 version.
59 """
60
61 # Keep the dict-less class hierarchy.
62 __slots__ = ()
63
64 @staticmethod
65 def __normalize_segments(
66 is_dir_pattern: bool,
67 pattern_segs: list[str],
68 ) -> tuple[Optional[list[str]], Optional[str]]:
69 """
70 Normalize the pattern segments to make processing easier.
71
72 *is_dir_pattern* (:class:`bool`) is whether the pattern is a directory
73 pattern (i.e., ends with a slash '/').
74
75 *pattern_segs* (:class:`list` of :class:`str`) contains the pattern
76 segments. This may be modified in place.
77
78 Returns a :class:`tuple` containing either:
79
80 - The normalized segments (:class:`list` of :class:`str`; or :data:`None`).
81
82 - The regular expression override (:class:`str` or :data:`None`).
83 """
84 if not pattern_segs[0]:
85 # A pattern beginning with a slash ('/') should match relative to the root
86 # directory. Remove the empty first segment to make the pattern relative
87 # to root.
88 del pattern_segs[0]
89
90 elif len(pattern_segs) == 1 or (len(pattern_segs) == 2 and not pattern_segs[1]):
91 # A single segment pattern with or without a trailing slash ('/') will
92 # match any descendant path. This is equivalent to "**/{pattern}". Prepend
93 # a double-asterisk segment to make the pattern relative to root.
94 if pattern_segs[0] != '**':
95 pattern_segs.insert(0, '**')
96
97 else:
98 # A pattern without a beginning slash ('/') but contains at least one
99 # prepended directory (e.g., "dir/{pattern}") should match relative to the
100 # root directory. No segment modification is needed.
101 pass
102
103 if not pattern_segs:
104 # After normalization, we end up with no pattern at all. This must be
105 # because the pattern is invalid.
106 raise ValueError("Pattern normalized to nothing.")
107
108 if not pattern_segs[-1]:
109 # A pattern ending with a slash ('/') will match all descendant paths if
110 # it is a directory but not if it is a regular file. This is equivalent to
111 # "{pattern}/**". Set the empty last segment to a double-asterisk to
112 # include all descendants.
113 pattern_segs[-1] = '**'
114
115 # EDGE CASE: Collapse duplicate double-asterisk sequences (i.e., '**/**').
116 # Iterate over the segments in reverse order and remove the duplicate double
117 # asterisks as we go.
118 for i in range(len(pattern_segs) - 1, 0, -1):
119 prev = pattern_segs[i-1]
120 seg = pattern_segs[i]
121 if prev == '**' and seg == '**':
122 del pattern_segs[i]
123
124 seg_count = len(pattern_segs)
125 if seg_count == 1 and pattern_segs[0] == '**':
126 if is_dir_pattern:
127 # The pattern "**/" will be normalized to "**", but it should match
128 # everything except for files in the root. Special case this pattern.
129 return (None, _DIR_MARK_CG)
130 else:
131 # The pattern "**" will match every path. Special case this pattern.
132 return (None, _MATCH_ALL)
133
134 elif (
135 seg_count == 2
136 and pattern_segs[0] == '**'
137 and pattern_segs[1] == '*'
138 ):
139 # The pattern "*" will be normalized to "**/*" and will match every
140 # path. Special case this pattern for efficiency.
141 return (None, _MATCH_ALL)
142
143 elif (
144 seg_count == 3
145 and pattern_segs[0] == '**'
146 and pattern_segs[1] == '*'
147 and pattern_segs[2] == '**'
148 ):
149 # The pattern "*/" will be normalized to "**/*/**" which will match every
150 # file not in the root directory. Special case this pattern for
151 # efficiency.
152 if is_dir_pattern:
153 return (None, _DIR_MARK_CG)
154 else:
155 return (None, '/')
156
157 # No regular expression override, return modified pattern segments.
158 return (pattern_segs, None)
159
160 @override
161 @classmethod
162 def pattern_to_regex(
163 cls,
164 pattern: AnyStr,
165 ) -> tuple[Optional[AnyStr], Optional[bool]]:
166 """
167 Convert the pattern into a regular expression.
168
169 *pattern* (:class:`str` or :class:`bytes`) is the pattern to convert into a
170 regular expression.
171
172 Returns a :class:`tuple` containing:
173
174 - *pattern* (:class:`str`, :class:`bytes` or :data:`None`) is the
175 uncompiled regular expression.
176
177 - *include* (:class:`bool` or :data:`None`) is whether matched files
178 should be included (:data:`True`), excluded (:data:`False`), or is a
179 null-operation (:data:`None`).
180 """
181 if isinstance(pattern, str):
182 pattern_str = pattern
183 return_type = str
184 elif isinstance(pattern, bytes):
185 pattern_str = pattern.decode(_BYTES_ENCODING)
186 return_type = bytes
187 else:
188 raise TypeError(f"{pattern=!r} is not a unicode or byte string.")
189
190 original_pattern = pattern_str
191 del pattern
192
193 if pattern_str.endswith('\\ '):
194 # EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends
195 # with a backslash is followed by a space, do not strip from the left.
196 pass
197 else:
198 # EDGE CASE: Leading spaces should be kept (only trailing spaces should be
199 # removed). Git does not remove leading spaces.
200 pattern_str = pattern_str.rstrip()
201
202 regex: Optional[str]
203 include: Optional[bool]
204
205 if not pattern_str:
206 # A blank pattern is a null-operation (neither includes nor excludes
207 # files).
208 return (None, None)
209
210 elif pattern_str.startswith('#'):
211 # A pattern starting with a hash ('#') serves as a comment (neither
212 # includes nor excludes files). Escape the hash with a backslash to match
213 # a literal hash (i.e., '\#').
214 return (None, None)
215
216 elif pattern_str == '/':
217 # EDGE CASE: According to `git check-ignore` (v2.4.1), a single '/' does
218 # not match any file.
219 return (None, None)
220
221 if pattern_str.startswith('!'):
222 # A pattern starting with an exclamation mark ('!') negates the pattern
223 # (exclude instead of include). Escape the exclamation mark with a
224 # backslash to match a literal exclamation mark (i.e., '\!').
225 include = False
226 # Remove leading exclamation mark.
227 pattern_str = pattern_str[1:]
228 else:
229 include = True
230
231 # Split pattern into segments.
232 orig_segs = pattern_str.split('/')
233
234 # Check whether the pattern is specifically a directory pattern before
235 # normalization.
236 is_dir_pattern = not orig_segs[-1]
237
238 # Normalize pattern to make processing easier.
239 try:
240 pattern_segs, override_regex = cls.__normalize_segments(
241 is_dir_pattern, orig_segs,
242 )
243 except ValueError as e:
244 raise GitIgnorePatternError((
245 f"Invalid git pattern: {original_pattern!r}"
246 )) from e # GitIgnorePatternError
247
248 if override_regex is not None:
249 # Use regex override.
250 regex = override_regex
251
252 elif pattern_segs is not None:
253 # Build regular expression from pattern.
254 try:
255 regex_parts = cls.__translate_segments(is_dir_pattern, pattern_segs)
256 except _RangeError:
257 # EDGE CASE: Git discards patterns with invalid range notation.
258 return (None, None)
259 except ValueError as e:
260 raise GitIgnorePatternError((
261 f"Invalid git pattern: {original_pattern!r}"
262 )) from e # GitIgnorePatternError
263
264 regex = ''.join(regex_parts)
265
266 else:
267 assert_unreachable((
268 f"{override_regex=} and {pattern_segs=} cannot both be null."
269 )) # assert_unreachable
270
271 # Encode regex if needed.
272 out_regex: AnyStr
273 if regex is not None and return_type is bytes:
274 regex_bytes = regex.encode(_BYTES_ENCODING)
275 out_regex = regex_bytes # type: ignore[assignment]
276 else:
277 out_regex = regex # type: ignore[assignment]
278
279 return (out_regex, include)
280
281 @classmethod
282 def __translate_segments(
283 cls,
284 is_dir_pattern: bool,
285 pattern_segs: list[str],
286 ) -> list[str]:
287 """
288 Translate the pattern segments to regular expressions.
289
290 *is_dir_pattern* (:class:`bool`) is whether the pattern is a directory
291 pattern (i.e., ends with a slash '/').
292
293 *pattern_segs* (:class:`list` of :class:`str`) contains the pattern
294 segments.
295
296 Raises :class:`_RangeError` if invalid range notation is found.
297
298 Returns the regular expression parts (:class:`list` of :class:`str`).
299 """
300 # Build regular expression from pattern.
301 out_parts = []
302 need_slash = False
303 end = len(pattern_segs) - 1
304 for i, seg in enumerate(pattern_segs):
305 if seg == '**':
306 if i == 0:
307 # A normalized pattern beginning with double-asterisks ('**') will
308 # match any leading path segments.
309 out_parts.append('^(?s:.+/)?')
310
311 elif i < end:
312 # A pattern with inner double-asterisks ('**') will match multiple (or
313 # zero) inner path segments.
314 out_parts.append('(?s:/.+)?')
315 need_slash = True
316
317 else:
318 assert i == end, (i, end)
319 # A normalized pattern ending with double-asterisks ('**') will match
320 # nonempty trailing path segments, not the parent directory itself.
321 if is_dir_pattern:
322 out_parts.append(_DIR_MARK_CG)
323 else:
324 out_parts.append('/[^/]')
325
326 else:
327 # Match path segment.
328 if i == 0:
329 # Anchor to root directory.
330 out_parts.append('^')
331
332 if need_slash:
333 out_parts.append('/')
334
335 if seg == '*':
336 # Match whole path segment.
337 out_parts.append('[^/]+')
338
339 else:
340 # Match segment glob pattern.
341 # - EDGE CASE: Git discards patterns with invalid range notation.
342 out_parts.append(cls._translate_segment_glob(seg, 'raise'))
343
344 if i == end:
345 # A pattern ending without a slash ('/') will match a file or a
346 # directory (with paths underneath it). E.g., "foo" matches "foo",
347 # "foo/bar", "foo/bar/baz", etc.
348 out_parts.append(_DIR_MARK_OPT)
349
350 need_slash = True
351
352 return out_parts