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