Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
224 / 224
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
IgnoreFile
100.00% covered (success)
100.00%
224 / 224
100.00% covered (success)
100.00%
7 / 7
116
100.00% covered (success)
100.00%
1 / 1
 add
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
18
 filter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ignores
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 notIgnores
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 test
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
10
 filterIterator
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 patternToRegex
100.00% covered (success)
100.00%
150 / 150
100.00% covered (success)
100.00%
1 / 1
83
1<?php
2/**
3 * Implementation of the gitignore "spec".
4 *
5 * @package automattic/ignorefile
6 */
7
8namespace Automattic;
9
10use Automattic\IgnoreFile\InvalidPatternException;
11use CallbackFilterIterator;
12use FilterIterator;
13use InvalidArgumentException;
14use Iterator;
15use RecursiveCallbackFilterIterator;
16use RecursiveIterator;
17use SplFileInfo;
18
19/**
20 * Implementation of the gitignore "spec".
21 *
22 * @link https://git-scm.com/docs/gitignore
23 */
24class IgnoreFile {
25
26    /**
27     * Set true to throw on invalid patterns.
28     *
29     * @var bool
30     */
31    public $strictMode = false;
32
33    /**
34     * Loaded patterns.
35     *
36     * @var array
37     */
38    private $patterns = array();
39
40    /**
41     * Add one or more pattern lines, or import from another IgnoreFile instance.
42     *
43     * The `$prefix` is intended for use when you're loading multiple `.gitignore` files from
44     * multiple places in the filesystem. As you load each file, pass the path to the file
45     * as `$prefix` (either relative to a common base or absolute).
46     *
47     * @param string|IgnoreFile|(string|IgnoreFile)[] $patterns Patterns to add. Acceptable values are:
48     *   - A string, possibly containing newlines, representing the contents of a `.gitignore` file.
49     *   - Another `IgnoreFile` instance, which will be merged into the current instance.
50     *   - An array of strings (no newlines allowed) and/or IgnoreFile instances.
51     * @param string                                  $prefix All (string) patterns must match relative to this prefix.
52     *   This is not applied to patterns copied from another Ignore instance.
53     * @throws InvalidArgumentException If arguments are invalid.
54     * @throws InvalidPatternException If patterns are invalid and `$this->strictMode` is set.
55     */
56    public function add( $patterns, $prefix = '' ) {
57        if ( '' !== $prefix && substr( $prefix, -1 ) !== '/' ) {
58            throw new InvalidArgumentException( 'Prefix must end in `/`' );
59        }
60
61        if ( is_string( $patterns ) ) {
62            if ( substr( $patterns, 0, 3 ) === "\xef\xbb\xbf" ) { // UTF-8 BOM
63                $patterns = substr( $patterns, 3 );
64            }
65            $patterns = explode( "\n", $patterns );
66        } elseif ( ! is_array( $patterns ) ) {
67            $patterns = array( $patterns );
68        }
69
70        foreach ( $patterns as $idx => $pat ) {
71            if ( $pat instanceof IgnoreFile ) {
72                $this->patterns = array_merge( $this->patterns, $pat->patterns );
73                continue;
74            }
75
76            $pat = (string) $pat;
77
78            // Line may not contain newlines. But DWIM and ignore trailing newlines.
79            $pat = rtrim( $pat, "\r\n" );
80            if ( strpos( $pat, "\r" ) !== false || strpos( $pat, "\n" ) !== false ) {
81                if ( $this->strictMode ) {
82                    throw new InvalidPatternException( "Pattern at index $idx may not contain newlines" );
83                }
84                continue;
85            }
86
87            // A blank line matches no files. Trailing spaces are ignored unless they are quoted with backslash ("\").
88            // A line starting with # serves as a comment.
89            if ( trim( $pat ) === '' || '#' === $pat[0] ) {
90                continue;
91            }
92
93            $orig = $pat;
94
95            // Trailing spaces are ignored unless they are quoted with backslash ("\").
96            $pat = preg_replace( '/(?<!\\\\) +$/', '', $pat );
97
98            // A prefix "!" negates the pattern.
99            $negate = '!' === $pat[0];
100            if ( $negate ) {
101                $pat = (string) substr( $pat, 1 );
102                if ( '' === $pat ) {
103                    if ( $this->strictMode ) {
104                        throw new InvalidPatternException( "Pattern at index $idx consists of only `!`" );
105                    }
106                    continue;
107                }
108            }
109
110            try {
111                $regex = $this->patternToRegex( $pat, $idx );
112            } catch ( InvalidPatternException $ex ) {
113                if ( $this->strictMode ) {
114                    throw $ex;
115                }
116                continue;
117            }
118
119            $this->patterns[] = array(
120                'prefix'  => $prefix,
121                'negate'  => $negate,
122                'pattern' => $orig,
123                'regex'   => $regex,
124            );
125        }
126    }
127
128    /**
129     * Filter an array of paths to remove any that are ignored.
130     *
131     * See `ignores()` for notes on how to pass paths.
132     *
133     * @param (string|SplFileInfo)[] $paths Paths.
134     * @return (string|SplFileInfo)[]
135     */
136    public function filter( array $paths ) {
137        return array_filter( $paths, array( $this, 'notIgnores' ) );
138    }
139
140    /**
141     * Indicate whether the path is ignored.
142     *
143     * If `$prefix` was used with `add()`, the paths passed should be equivalent (relative to the
144     * same common base, or absolute if `add()` was passed absolute paths).
145     *
146     * Directories must include a trailing `/` to be recognized as such. Without that, they'll be
147     * treated as files.
148     *
149     * @param string|SplFileInfo $path Path to test.
150     * @return bool If the path is ignored.
151     */
152    public function ignores( $path ) {
153        $ret = $this->test( $path );
154        return $ret['ignored'];
155    }
156
157    /**
158     * Indicate whether the path is not ignored.
159     *
160     * This is, basically, `! $this->ignores( $path )`. It's convenient for
161     * passing to `array_filter()` or the like.
162     *
163     * @param string $path Path to test.
164     * @return bool If the path is not ignored.
165     */
166    public function notIgnores( $path ) {
167        $ret = $this->test( $path );
168        return ! $ret['ignored'];
169    }
170
171    /**
172     * Indicate whether the path is ignored or unignored.
173     *
174     * There are three possible return values:
175     *
176     * - `array( 'ignored' => true, 'unignored' => false, 'pattern' => string )` => Path is ignored.
177     * - `array( 'ignored' => false, 'unignored' => true, 'pattern' => string )` => Path is explicitly unignored.
178     * - `array( 'ignored' => false, 'unignored' => false, 'pattern' => null )` => Path is neither ignored nor unignored.
179     *
180     * @param string|SplFileInfo $path Path to test.
181     * @return array As above.
182     */
183    public function test( $path ) {
184        if ( $path instanceof SplFileInfo ) {
185            $path = $path->isDir() ? "$path/" : "$path";
186        }
187
188        $parentPath = dirname( $path ) . '/';
189        if ( './' !== $parentPath && $parentPath !== $path ) {
190            $parent = $this->test( $parentPath );
191            if ( $parent['ignored'] ) {
192                return $parent;
193            }
194        }
195
196        $ret = array(
197            'ignored'   => false,
198            'unignored' => false,
199            'pattern'   => null,
200        );
201
202        foreach ( $this->patterns as $pat ) {
203            // Prefix match.
204            if ( '' === $pat['prefix'] ) {
205                $p = $path;
206            } else {
207                $l = strlen( $pat['prefix'] );
208                if ( substr( $path, 0, $l ) !== $pat['prefix'] ) {
209                    continue;
210                }
211                $p = (string) substr( $path, $l );
212            }
213
214            if ( preg_match( $pat['regex'], $p ) ) {
215                $ret = array(
216                    'ignored'   => ! $pat['negate'],
217                    'unignored' => $pat['negate'],
218                    'pattern'   => $pat['pattern'],
219                );
220            }
221        }
222
223        return $ret;
224    }
225
226    /**
227     * Filter an iterator.
228     *
229     * When using FileSystemIterator or its subclasses, do not use CURRENT_AS_PATHNAME
230     * as that does not include the necessary trailing `/` on directory names.
231     *
232     * @param Iterator $iter Iterator to filter.
233     * @return FilterIterator|FilterIterator&RecursiveIterator Filtered iterator. If the input implements `RecursiveIterator`, the returned iterator does too.
234     */
235    public function filterIterator( Iterator $iter ) {
236        if ( $iter instanceof RecursiveIterator ) {
237            return new RecursiveCallbackFilterIterator( $iter, array( $this, 'notIgnores' ) );
238        }
239        return new CallbackFilterIterator( $iter, array( $this, 'notIgnores' ) );
240    }
241
242    /**
243     * Convert a pattern to a PCRE regex.
244     *
245     * @param string     $pat Pattern.
246     * @param string|int $idx Index for errors.
247     * @return string Regex.
248     * @throws InvalidPatternException If pattern is invalid.
249     */
250    protected function patternToRegex( $pat, $idx ) {
251        $orig = $pat;
252
253        // If there is a separator at the beginning or middle (or both) of the pattern, then the pattern is relative to the $prefix. Otherwise, it has an implicit "**/" at the start.
254        $pos = strpos( $pat, '/' );
255        if ( false === $pos || strlen( $pat ) - 1 === $pos ) {
256            $pat = ( '/' === $pat[0] ? '**' : '**/' ) . $pat;
257        }
258
259        // Collapse any multiple "/".
260        $pat = preg_replace( '#//+#', '/', $pat );
261
262        // Collapse any "/**/**/".
263        $pat = preg_replace( '#(?:^|/)\*\*/(?:\*\*/)+#', '/**/', $pat );
264
265        $re = '#';
266
267        // A leading "**/" means match in all directories. Otherwise, it's anchored to the start.
268        // A leading "/**/" is the same.
269        if ( substr( $pat, 0, 3 ) === '**/' || substr( $pat, 0, 4 ) === '/**/' ) {
270            $re .= '(?:^|/)';
271            $pat = (string) substr( $pat, '/' === $pat[0] ? 4 : 3 );
272        } else {
273            $re .= '^';
274            // Ignore a leading "/".
275            if ( '/' === $pat[0] ) {
276                $pat = (string) substr( $pat, 1 );
277            }
278        }
279
280        $atDir = true;
281        $noBT  = false;
282        while ( '' !== $pat ) {
283            // A trailing "/**" matches everything inside.
284            if ( '/**' === $pat ) {
285                $re   .= ( $noBT ? ')' : '' ) . ( $atDir ? '' : '/' ) . '.++';
286                $atDir = true;
287                $noBT  = false;
288                break;
289            }
290
291            // A slash followed by two consecutive asterisks then a slash matches zero or more directories.
292            if ( substr( $pat, 0, 4 ) === '/**/' ) {
293                // Avoid creating a poorly-performing regex if someone does something weird with backslashes or something that stops the collapsing above from working.
294                if ( substr( $re, -11 ) !== '(?:[^/]+/)*' ) {
295                    $re .= ( $noBT ? ')' : '' ) . ( $atDir ? '' : '/' ) . '(?:[^/]+/)*';
296                }
297                $pat   = (string) substr( $pat, 4 );
298                $atDir = true;
299                $noBT  = false;
300                continue;
301            }
302
303            // An asterisk "*" matches anything except a slash.
304            if ( '*' === $pat[0] ) {
305                // Avoid creating a poorly-performing regex if someone does like "/foo**".
306                if ( substr( $re, -5 ) !== '[^/]*' ) {
307                    if ( $atDir && ! $noBT ) {
308                        $re  .= '(?>';
309                        $noBT = true;
310                    }
311                    // Use a lookahead to ensure the component isn't empty, if the "*" is at the start of a component.
312                    if ( $atDir ) {
313                        $re .= '(?=[^/])';
314                    }
315                    $re .= '[^/]*';
316                }
317                $pat   = (string) substr( $pat, 1 );
318                $atDir = false;
319                continue;
320            }
321
322            // The character "?" matches any one character except "/".
323            if ( '?' === $pat[0] ) {
324                if ( $atDir && ! $noBT ) {
325                    $re  .= '(?>';
326                    $noBT = true;
327                }
328                $re   .= '[^/]';
329                $pat   = (string) substr( $pat, 1 );
330                $atDir = false;
331                continue;
332            }
333
334            // Probably a bracket expression. Complex.
335            if ( '[' === $pat[0] ) {
336                $pat           = (string) substr( $pat, 1 );
337                $savedPat      = $pat;
338                $cre           = '';
339                $needLookahead = false;
340
341                // If the charset is negated, we also need to include `/` in the list of characters to exclude.
342                // Note use of '^' as the negator is unspecified behavior.
343                if ( '' !== $pat && ( '!' === $pat[0] || '^' === $pat[0] ) ) {
344                    $cre .= '^/';
345                    $pat  = (string) substr( $pat, 1 );
346                }
347
348                $first = true;
349                while ( '' !== $pat && ( $first || ']' !== $pat[0] ) ) {
350                    $first = false;
351
352                    // Special stuff.
353                    if ( substr( $pat, 0, 2 ) === '[.' ) {
354                        throw new InvalidPatternException( "Collating symbols (`[.` inside a bracket expression) are not supported (in pattern at index $idx)" );
355                    }
356                    if ( substr( $pat, 0, 2 ) === '[=' ) {
357                        throw new InvalidPatternException( "Equivalence classes (`[=` inside a bracket expression) are not supported (in pattern at index $idx)" );
358                    }
359                    if ( substr( $pat, 0, 2 ) === '[:' ) {
360                        $pos = strpos( $pat, ':]' );
361                        if ( false === $pos ) {
362                            throw new InvalidPatternException( "Invalid character class in bracket expression near `$pat` (in pattern at index $idx)" );
363                        }
364                        $class = substr( $pat, 0, $pos + 2 );
365                        $pat   = (string) substr( $pat, $pos + 2 );
366                        switch ( $class ) {
367                            case '[:alnum:]': // @codeCoverageIgnore
368                            case '[:alpha:]': // @codeCoverageIgnore
369                            case '[:blank:]': // @codeCoverageIgnore
370                            case '[:cntrl:]': // @codeCoverageIgnore
371                            case '[:digit:]': // @codeCoverageIgnore
372                            case '[:lower:]': // @codeCoverageIgnore
373                            case '[:space:]': // @codeCoverageIgnore
374                            case '[:upper:]': // @codeCoverageIgnore
375                            case '[:xdigit:]': // @codeCoverageIgnore
376                                $cre .= $class;
377                                break;
378                            case '[:graph:]': // @codeCoverageIgnore
379                            case '[:print:]': // @codeCoverageIgnore
380                            case '[:punct:]': // @codeCoverageIgnore
381                                // These contain "/", without an easy way to exclude it. So use a lookahead.
382                                $needLookahead = true;
383                                $cre          .= $class;
384                                break;
385                            default:
386                                throw new InvalidPatternException( "Unrecognized character class $class in bracket expression (in pattern at index $idx)" );
387                        }
388                        continue;
389                    }
390
391                    // Range or character.
392                    $c1  = mb_substr( $pat, 0, 1, 'UTF8' );
393                    $pat = mb_substr( $pat, 1, null, 'UTF8' );
394                    if ( '\\' === $c1 ) {
395                        if ( '' === $pat ) {
396                            break;
397                        }
398                        $c1  = mb_substr( $pat, 0, 1, 'UTF8' );
399                        $pat = mb_substr( $pat, 1, null, 'UTF8' );
400                    }
401                    if ( strlen( $pat ) > 1 && '-' === $pat[0] && ']' !== $pat[1] ) {
402                        $c2  = mb_substr( $pat, 1, 1, 'UTF8' );
403                        $pat = mb_substr( $pat, 2, null, 'UTF8' );
404                        if ( '\\' === $c2 ) {
405                            if ( '' === $pat ) {
406                                break;
407                            }
408                            $c2  = mb_substr( $pat, 0, 1, 'UTF8' );
409                            $pat = mb_substr( $pat, 1, null, 'UTF8' );
410                        }
411
412                        // If the start of the range is `/`, move it past.
413                        if ( '/' === $c1 ) {
414                            $c1 = '0';
415                        }
416                        // If the end of the range is `/`, move it back.
417                        if ( '/' === $c2 ) {
418                            $c2 = '.';
419                        }
420                        // Inverted ranges match nothing.
421                        if ( strcmp( $c1, $c2 ) > 0 ) {
422                            continue;
423                        }
424                        if ( $c1 === $c2 ) {
425                            $cre .= preg_quote( $c1, '#' );
426                        } elseif ( strcmp( $c1, '/' ) < 0 && strcmp( '/', $c2 ) < 0 ) {
427                            // Range contains `/`, split it.
428                            $cre .= '.' !== $c1 ? preg_quote( $c1, '#' ) . '-.' : '.';
429                            $cre .= '0' !== $c2 ? '0-' . preg_quote( $c2, '#' ) : '0';
430                        } else {
431                            $cre .= preg_quote( $c1, '#' ) . '-' . preg_quote( $c2, '#' );
432                        }
433                    } elseif ( '/' !== $c1 ) { // If it's a "/" for a positive pattern, we need to omit it. If it's a "/" for a negated pattern, we already included it.
434                        $cre .= preg_quote( $c1, '#' );
435                    }
436                }
437
438                // If we reached the end of the pattern, it's not actually a bracket expression. Record a literal "[" and continue.
439                if ( '' === $pat ) {
440                    if ( $atDir && ! $noBT ) {
441                        $re  .= '(?>';
442                        $noBT = true;
443                    }
444                    $re   .= preg_quote( '[', '#' );
445                    $pat   = $savedPat;
446                    $atDir = false;
447                    continue;
448                }
449
450                // Check for an attempt to match nothing.
451                if ( '' === $cre ) {
452                    return '#(*FAIL)#';
453                }
454
455                if ( $atDir && ! $noBT ) {
456                    $re  .= '(?>';
457                    $noBT = true;
458                }
459                if ( $needLookahead ) {
460                    // If we couldn't exclude it when building the bracket expression, use a lookahead to ensure it doesn't match a "/".
461                    $re .= '(?!/)';
462                }
463                $re   .= "[$cre]";
464                $pat   = (string) substr( $pat, 1 ); // Remove the trailing ']'.
465                $atDir = false;
466                continue;
467            }
468
469            // Outside of a range, backslash escapes a character.
470            if ( '\\' === $pat ) {
471                // If a pattern ends with an unescaped <backslash>, it is unspecified whether the pattern does not match anything or the pattern is treated as invalid.
472                // We treat it as invalid here.
473                throw new InvalidPatternException( "Unexpected trailing backslash in pattern `$orig` at index $idx" );
474            }
475            if ( '\\' === $pat[0] ) {
476                if ( '/' === $pat[1] ) {
477                    // Special case this becuase of so much other special "/" handling.
478                    $pat = (string) substr( $pat, 1 );
479                    continue;
480                }
481                if ( $atDir && ! $noBT ) {
482                    $re  .= '(?>';
483                    $noBT = true;
484                }
485                $re   .= preg_quote( mb_substr( $pat, 1, 1, 'UTF8' ), '#' );
486                $pat   = mb_substr( $pat, 2, null, 'UTF8' );
487                $atDir = false;
488                continue;
489            }
490
491            // "/" separates path components.
492            if ( '/' === $pat[0] ) {
493                $re   .= ( $noBT ? ')' : '' ) . ( $atDir ? '' : '/' );
494                $pat   = (string) substr( $pat, 1 );
495                $atDir = true;
496                $noBT  = false;
497                continue;
498            }
499
500            // Else, it's just a regular character.
501            if ( $atDir && ! $noBT ) {
502                $re  .= '(?>';
503                $noBT = true;
504            }
505            $re   .= preg_quote( mb_substr( $pat, 0, 1, 'UTF8' ), '#' );
506            $pat   = mb_substr( $pat, 1, null, 'UTF8' );
507            $atDir = false;
508        }
509
510        $re .= ( $noBT ? ')' : '' ) . ( $atDir ? '' : '/?' ) . '$#u';
511
512        return $re;
513    }
514}