Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.92% covered (success)
98.92%
92 / 93
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
JetpackPhpcsFilter
98.92% covered (success)
98.92%
92 / 93
87.50% covered (warning)
87.50%
7 / 8
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
10
 reachedBaseDir
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 loadIgnoreFiles
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
14
 getConfigAndRuleset
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 shouldProcessFile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 shouldIgnorePath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 current
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 getChildren
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * A filter for PHP CodeSniffer to add support for .phpcsignore files and per-directory configuration files.
4 *
5 * @package automattic/jetpack-phpcs-filter
6 */
7
8namespace PHP_CodeSniffer\Filters\Automattic;
9
10use Automattic\IgnoreFile;
11use PHP_CodeSniffer\Config;
12use PHP_CodeSniffer\Exceptions\DeepExitException;
13use PHP_CodeSniffer\Exceptions\RuntimeException;
14use PHP_CodeSniffer\Files\LocalFile;
15use PHP_CodeSniffer\Filters\Filter;
16use PHP_CodeSniffer\Ruleset;
17use PHP_CodeSniffer\Util;
18use SplFileInfo;
19
20/**
21 * A filter for PHP CodeSniffer to add support for .phpcsignore files and per-directory configuration files.
22 */
23class JetpackPhpcsFilter extends Filter {
24
25    /**
26     * Enable generation of LocalFile objects instead of string|SplFileInfo.
27     *
28     * @var boolean
29     */
30    protected $produceLocalFileObjects = true;
31
32    /**
33     * Base directory for the scan.
34     *
35     * @var string
36     */
37    protected $filterBaseDir;
38
39    /**
40     * Per-directory config file name.
41     *
42     * @var string
43     */
44    protected $perDirFileName = '.phpcs.dir.xml';
45
46    /**
47     * Process ignore files.
48     *
49     * @var boolean
50     */
51    protected $doIgnore = true;
52
53    /**
54     * Process .gitignore files.
55     *
56     * @var boolean
57     */
58    protected $useGitignore = true;
59
60    /**
61     * IgnoreFile instance for processing .phpcsignore.
62     *
63     * @var IgnoreFile
64     */
65    protected $ignoreFile;
66
67    /**
68     * Whether a .phpcsignore was checked for for a path.
69     *
70     * @var bool[]
71     */
72    protected $ignoreLoaded = array();
73
74    /**
75     * Cache of Config and Ruleset objects by directory.
76     *
77     * @var array
78     */
79    protected $configCache = array();
80
81    /**
82     * Constructs a filter.
83     *
84     * @param \RecursiveIterator       $iterator The iterator we are using to get file paths.
85     * @param string                   $basedir The top-level path we are filtering.
86     * @param \PHP_CodeSniffer\Config  $config The config data for the run.
87     * @param \PHP_CodeSniffer\Ruleset $ruleset The ruleset used for the run.
88     * @param JetpackPhpcsFilter|null  $copyFrom Used from getChildren().
89     * @throws DeepExitException On error.
90     */
91    public function __construct( $iterator, $basedir, Config $config, Ruleset $ruleset, ?JetpackPhpcsFilter $copyFrom = null ) {
92        parent::__construct( $iterator, $basedir, $config, $ruleset );
93
94        if ( $copyFrom ) {
95            if ( $this->ruleset === $copyFrom->ruleset ) {
96                // Only copy these if the ruleset is the same. If it changed, these need to be regenerated.
97                $this->ignoreDirPatterns  = $copyFrom->ignoreDirPatterns;
98                $this->ignoreFilePatterns = $copyFrom->ignoreFilePatterns;
99            }
100            $this->acceptedPaths  = $copyFrom->acceptedPaths;
101            $this->filterBaseDir  = $copyFrom->filterBaseDir;
102            $this->perDirFileName = $copyFrom->perDirFileName;
103            $this->doIgnore       = $copyFrom->doIgnore;
104            $this->useGitignore   = $copyFrom->useGitignore;
105            $this->ignoreFile     = $copyFrom->ignoreFile;
106            $this->ignoreLoaded   = &$copyFrom->ignoreLoaded;
107            $this->configCache    = &$copyFrom->configCache;
108            return;
109        }
110
111        $noIgnore = $config->getConfigData( 'jetpack-filter-no-ignore' );
112        if ( null !== $noIgnore ) {
113            $this->doIgnore = ! $noIgnore;
114        }
115
116        $useGitignore = $config->getConfigData( 'jetpack-filter-use-gitignore' );
117        if ( null !== $useGitignore ) {
118            $this->useGitignore = (bool) $useGitignore;
119        }
120
121        $perDirFileName = $config->getConfigData( 'jetpack-filter-perdir-file' );
122        if ( null !== $perDirFileName ) {
123            $this->perDirFileName = $perDirFileName;
124        }
125
126        $dir = $config->getConfigData( 'jetpack-filter-basedir' );
127        if ( null === $dir ) {
128            $dir = '.';
129        }
130
131        $realDir = Util\Common::realpath( $dir );
132        // @codeCoverageIgnoreStart
133        if ( false === $realDir ) {
134            throw new DeepExitException( 'ERROR: ' . static::class . ": Specified base dir $dir does not exist.", 3 );
135        }
136        if ( ! is_dir( $realDir ) ) {
137            throw new DeepExitException( 'ERROR: ' . static::class . ": Specified base dir $dir is not a directory.", 3 );
138        }
139        // @codeCoverageIgnoreEnd
140        $this->filterBaseDir = $realDir;
141
142        // @codeCoverageIgnoreStart
143        if ( PHP_CODESNIFFER_VERBOSITY > 0 ) {
144            echo "\n" . static::class . ": Using base directory $this->filterBaseDir\n";
145        }
146        // @codeCoverageIgnoreEnd
147
148        $this->ignoreFile = new IgnoreFile();
149
150        $this->configCache[ $this->filterBaseDir ] = array( $config, $ruleset );
151    }
152
153    /**
154     * Test if we've reached the base directory.
155     *
156     * @param string $dir Dir to match.
157     * @return bool
158     */
159    protected function reachedBaseDir( $dir ) {
160        $realDir = Util\Common::realpath( $dir );
161        return ( $realDir === $this->filterBaseDir || substr( "$this->filterBaseDir/", 0, strlen( $realDir ) + 1 ) === "$realDir/" );
162    }
163
164    /**
165     * Load the .phpcsignore file for a path.
166     *
167     * @param string $path Path.
168     */
169    protected function loadIgnoreFiles( $path ) {
170        if ( ! $this->doIgnore ) {
171            return;
172        }
173
174        $dir = is_dir( $path ) ? (string) $path : dirname( $path );
175
176        // Already cached?
177        if ( ! empty( $this->ignoreLoaded[ $dir ] ) ) {
178            return;
179        }
180
181        // Have we passed the base dir? But still process if this *is* the base dir.
182        if ( $this->reachedBaseDir( $dir ) && Util\Common::realpath( $dir ) !== $this->filterBaseDir ) {
183            return;
184        }
185
186        // Mark as loaded, since we're doing so now.
187        $this->ignoreLoaded[ $dir ] = true;
188
189        // Load any parent .phpcsignore.
190        $parent = dirname( $dir );
191        if ( $parent !== $dir ) {
192            $this->loadIgnoreFiles( $parent );
193        }
194
195        // Read any .gitignore and .phpcsignore in the current dir now.
196        if ( $this->useGitignore && file_exists( "$dir/.gitignore" ) ) {
197            // @codeCoverageIgnoreStart
198            if ( PHP_CODESNIFFER_VERBOSITY > 0 ) {
199                echo static::class . ": Loading $dir/.gitignore\n";
200            }
201            // @codeCoverageIgnoreEnd
202            $data = file_get_contents( "$dir/.gitignore" );
203            if ( false === $data ) {
204                fprintf( STDERR, "WARN: Failed to read gitignore file %s\n", "$dir/.gitignore" ); // @codeCoverageIgnore
205            } else {
206                $this->ignoreFile->add( $data, "$dir/" );
207            }
208        }
209        if ( file_exists( "$dir/.phpcsignore" ) ) {
210            // @codeCoverageIgnoreStart
211            if ( PHP_CODESNIFFER_VERBOSITY > 0 ) {
212                echo static::class . ": Loading $dir/.phpcsignore\n";
213            }
214            // @codeCoverageIgnoreEnd
215            $data = file_get_contents( "$dir/.phpcsignore" );
216            if ( false === $data ) {
217                fprintf( STDERR, "WARN: Failed to read phpcsignore file %s\n", "$dir/.phpcsignore" ); // @codeCoverageIgnore
218            } else {
219                $this->ignoreFile->add( $data, "$dir/" );
220            }
221        }
222    }
223
224    /**
225     * Fetch the Config and Ruleset for a path.
226     *
227     * @param string $path Path.
228     * @return array [ Config, Ruleset ]
229     * @throws DeepExitException On error.
230     */
231    public function getConfigAndRuleset( $path ) {
232        $dir = is_dir( $path ) ? (string) $path : dirname( $path );
233        if ( $this->reachedBaseDir( $dir ) ) {
234            $dir = $this->filterBaseDir;
235        }
236
237        // Already cached?
238        if ( isset( $this->configCache[ $dir ] ) ) {
239            return $this->configCache[ $dir ];
240        }
241
242        // Nope. Fetch parent, then possibly modify.
243        $parent                   = dirname( $dir );
244        list( $config, $ruleset ) = $this->getConfigAndRuleset( $parent === $dir ? $this->filterBaseDir : $parent );
245
246        // Per directory config file found? Create a new Config and Ruleset for it.
247        if ( file_exists( "$dir/$this->perDirFileName" ) ) {
248            // @codeCoverageIgnoreStart
249            if ( PHP_CODESNIFFER_VERBOSITY > 0 ) {
250                echo static::class . ": Loading $dir/$this->perDirFileName\n";
251            }
252            // @codeCoverageIgnoreEnd
253            $config            = clone $config;
254            $config->standards = array_merge( $config->standards, array( "$dir/$this->perDirFileName" ) );
255            try {
256                $ruleset = new Ruleset( $config );
257                // @codeCoverageIgnoreStart
258            } catch ( RuntimeException $e ) {
259                $error  = 'ERROR: ' . $e->getMessage() . PHP_EOL . PHP_EOL;
260                $error .= $this->config->printShortUsage( true );
261                throw new DeepExitException( $error, 3 );
262            }
263            // @codeCoverageIgnoreEnd
264        }
265
266        $this->configCache[ $dir ] = array( $config, $ruleset );
267        return $this->configCache[ $dir ];
268    }
269
270    /**
271     * Check if a path should be processed.
272     *
273     * @param string|SplFileInfo $path The path to the file or directory being checked.
274     * @return bool
275     */
276    protected function shouldProcessFile( $path ) {
277        $old = array( $this->config, $this->ruleset );
278        try {
279            list( $this->config, $this->ruleset ) = $this->getConfigAndRuleset( $path );
280            $ret                                  = parent::shouldProcessFile( $path );
281        } finally {
282            list( $this->config, $this->ruleset ) = $old;
283        }
284        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable,PhanTypeMismatchReturnNullable -- https://github.com/phan/phan/issues/4419
285        return $ret;
286    }
287
288    /**
289     * Check if a path should be ignored.
290     *
291     * @param string|SplFileInfo $path The path to the file or directory being checked.
292     * @return bool
293     */
294    protected function shouldIgnorePath( $path ) {
295        $this->loadIgnoreFiles( $path );
296        if ( $this->ignoreFile->ignores( is_dir( $path ) ? "$path/" : $path ) ) {
297            return true;
298        }
299
300        $old = array( $this->config, $this->ruleset );
301        try {
302            list( $this->config, $this->ruleset ) = $this->getConfigAndRuleset( $path );
303            $ret                                  = parent::shouldIgnorePath( $path );
304        } finally {
305            list( $this->config, $this->ruleset ) = $old;
306        }
307        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable,PhanTypeMismatchReturnNullable -- https://github.com/phan/phan/issues/4419
308        return $ret;
309    }
310
311    /**
312     * Map the input string or SplFileInfo into a LocalFile.
313     *
314     * @return string|LocalFile
315     */
316    public function current() {
317        $iterator = $this->getInnerIterator();
318        '@phan-var \RecursiveIterator $iterator'; // Phan has the type wrong somehow. This is the iterator passed to `__construct()`.
319        $filePath = (string) $iterator->current();
320        if ( is_dir( $filePath ) ) {
321            return $filePath;
322        }
323        list( $config, $ruleset ) = $this->getConfigAndRuleset( $filePath );
324        return new LocalFile( $filePath, $ruleset, $config );
325    }
326
327    /**
328     * Returns an iterator for the current entry.
329     *
330     * @return static
331     */
332    public function getChildren() {
333        $iterator = $this->getInnerIterator();
334        '@phan-var \RecursiveIterator $iterator'; // Phan has the type wrong somehow. This is the iterator passed to `__construct()`.
335        $filePath                 = (string) $iterator->current();
336        list( $config, $ruleset ) = $this->getConfigAndRuleset( $filePath );
337        return new static(
338            new \RecursiveDirectoryIterator( $filePath, \RecursiveDirectoryIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS ),
339            $this->basedir,
340            $config,
341            $ruleset,
342            $this
343        );
344    }
345}