1<?php
2/**
3 * Stores the rules used to check and fix files.
4 *
5 * A ruleset object directly maps to a ruleset XML file.
6 *
7 * @author    Greg Sherwood <gsherwood@squiz.net>
8 * @copyright 2006-2015 Squiz Pty Ltd (ABN 77 084 670 600)
9 * @license   https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
10 */
11
12namespace PHP_CodeSniffer;
13
14use PHP_CodeSniffer\Exceptions\RuntimeException;
15use PHP_CodeSniffer\Util;
16
17class Ruleset
18{
19
20    /**
21     * The name of the coding standard being used.
22     *
23     * If a top-level standard includes other standards, or sniffs
24     * from other standards, only the name of the top-level standard
25     * will be stored in here.
26     *
27     * If multiple top-level standards are being loaded into
28     * a single ruleset object, this will store a comma separated list
29     * of the top-level standard names.
30     *
31     * @var string
32     */
33    public $name = '';
34
35    /**
36     * A list of file paths for the ruleset files being used.
37     *
38     * @var string[]
39     */
40    public $paths = [];
41
42    /**
43     * A list of regular expressions used to ignore specific sniffs for files and folders.
44     *
45     * Is also used to set global exclude patterns.
46     * The key is the regular expression and the value is the type
47     * of ignore pattern (absolute or relative).
48     *
49     * @var array<string, string>
50     */
51    public $ignorePatterns = [];
52
53    /**
54     * A list of regular expressions used to include specific sniffs for files and folders.
55     *
56     * The key is the sniff code and the value is an array with
57     * the key being a regular expression and the value is the type
58     * of ignore pattern (absolute or relative).
59     *
60     * @var array<string, array<string, string>>
61     */
62    public $includePatterns = [];
63
64    /**
65     * An array of sniff objects that are being used to check files.
66     *
67     * The key is the fully qualified name of the sniff class
68     * and the value is the sniff object.
69     *
70     * @var array<string, \PHP_CodeSniffer\Sniffs\Sniff>
71     */
72    public $sniffs = [];
73
74    /**
75     * A mapping of sniff codes to fully qualified class names.
76     *
77     * The key is the sniff code and the value
78     * is the fully qualified name of the sniff class.
79     *
80     * @var array<string, string>
81     */
82    public $sniffCodes = [];
83
84    /**
85     * An array of token types and the sniffs that are listening for them.
86     *
87     * The key is the token name being listened for and the value
88     * is the sniff object.
89     *
90     * @var array<int, \PHP_CodeSniffer\Sniffs\Sniff>
91     */
92    public $tokenListeners = [];
93
94    /**
95     * An array of rules from the ruleset.xml file.
96     *
97     * It may be empty, indicating that the ruleset does not override
98     * any of the default sniff settings.
99     *
100     * @var array<string, mixed>
101     */
102    public $ruleset = [];
103
104    /**
105     * The directories that the processed rulesets are in.
106     *
107     * @var string[]
108     */
109    protected $rulesetDirs = [];
110
111    /**
112     * The config data for the run.
113     *
114     * @var \PHP_CodeSniffer\Config
115     */
116    private $config = null;
117
118
119    /**
120     * Initialise the ruleset that the run will use.
121     *
122     * @param \PHP_CodeSniffer\Config $config The config data for the run.
123     *
124     * @return void
125     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If no sniffs were registered.
126     */
127    public function __construct(Config $config)
128    {
129        $this->config = $config;
130        $restrictions = $config->sniffs;
131        $exclusions   = $config->exclude;
132        $sniffs       = [];
133
134        $standardPaths = [];
135        foreach ($config->standards as $standard) {
136            $installed = Util\Standards::getInstalledStandardPath($standard);
137            if ($installed === null) {
138                $standard = Util\Common::realpath($standard);
139                if (is_dir($standard) === true
140                    && is_file(Util\Common::realpath($standard.DIRECTORY_SEPARATOR.'ruleset.xml')) === true
141                ) {
142                    $standard = Util\Common::realpath($standard.DIRECTORY_SEPARATOR.'ruleset.xml');
143                }
144            } else {
145                $standard = $installed;
146            }
147
148            $standardPaths[] = $standard;
149        }
150
151        foreach ($standardPaths as $standard) {
152            $ruleset = @simplexml_load_string(file_get_contents($standard));
153            if ($ruleset !== false) {
154                $standardName = (string) $ruleset['name'];
155                if ($this->name !== '') {
156                    $this->name .= ', ';
157                }
158
159                $this->name .= $standardName;
160
161                // Allow autoloading of custom files inside this standard.
162                if (isset($ruleset['namespace']) === true) {
163                    $namespace = (string) $ruleset['namespace'];
164                } else {
165                    $namespace = basename(dirname($standard));
166                }
167
168                Autoload::addSearchPath(dirname($standard), $namespace);
169            }
170
171            if (defined('PHP_CODESNIFFER_IN_TESTS') === true && empty($restrictions) === false) {
172                // In unit tests, only register the sniffs that the test wants and not the entire standard.
173                try {
174                    foreach ($restrictions as $restriction) {
175                        $sniffs = array_merge($sniffs, $this->expandRulesetReference($restriction, dirname($standard)));
176                    }
177                } catch (RuntimeException $e) {
178                    // Sniff reference could not be expanded, which probably means this
179                    // is an installed standard. Let the unit test system take care of
180                    // setting the correct sniff for testing.
181                    return;
182                }
183
184                break;
185            }
186
187            if (PHP_CODESNIFFER_VERBOSITY === 1) {
188                echo "Registering sniffs in the $standardName standard... ";
189                if (count($config->standards) > 1 || PHP_CODESNIFFER_VERBOSITY > 2) {
190                    echo PHP_EOL;
191                }
192            }
193
194            $sniffs = array_merge($sniffs, $this->processRuleset($standard));
195        }//end foreach
196
197        // Ignore sniff restrictions if caching is on.
198        if ($config->cache === true) {
199            $restrictions = [];
200            $exclusions   = [];
201        }
202
203        $sniffRestrictions = [];
204        foreach ($restrictions as $sniffCode) {
205            $parts     = explode('.', strtolower($sniffCode));
206            $sniffName = $parts[0].'\sniffs\\'.$parts[1].'\\'.$parts[2].'sniff';
207            $sniffRestrictions[$sniffName] = true;
208        }
209
210        $sniffExclusions = [];
211        foreach ($exclusions as $sniffCode) {
212            $parts     = explode('.', strtolower($sniffCode));
213            $sniffName = $parts[0].'\sniffs\\'.$parts[1].'\\'.$parts[2].'sniff';
214            $sniffExclusions[$sniffName] = true;
215        }
216
217        $this->registerSniffs($sniffs, $sniffRestrictions, $sniffExclusions);
218        $this->populateTokenListeners();
219
220        $numSniffs = count($this->sniffs);
221        if (PHP_CODESNIFFER_VERBOSITY === 1) {
222            echo "DONE ($numSniffs sniffs registered)".PHP_EOL;
223        }
224
225        if ($numSniffs === 0) {
226            throw new RuntimeException('No sniffs were registered');
227        }
228
229    }//end __construct()
230
231
232    /**
233     * Prints a report showing the sniffs contained in a standard.
234     *
235     * @return void
236     */
237    public function explain()
238    {
239        $sniffs = array_keys($this->sniffCodes);
240        sort($sniffs);
241
242        ob_start();
243
244        $lastStandard = null;
245        $lastCount    = '';
246        $sniffCount   = count($sniffs);
247
248        // Add a dummy entry to the end so we loop
249        // one last time and clear the output buffer.
250        $sniffs[] = '';
251
252        echo PHP_EOL."The $this->name standard contains $sniffCount sniffs".PHP_EOL;
253
254        ob_start();
255
256        foreach ($sniffs as $i => $sniff) {
257            if ($i === $sniffCount) {
258                $currentStandard = null;
259            } else {
260                $currentStandard = substr($sniff, 0, strpos($sniff, '.'));
261                if ($lastStandard === null) {
262                    $lastStandard = $currentStandard;
263                }
264            }
265
266            if ($currentStandard !== $lastStandard) {
267                $sniffList = ob_get_contents();
268                ob_end_clean();
269
270                echo PHP_EOL.$lastStandard.' ('.$lastCount.' sniff';
271                if ($lastCount > 1) {
272                    echo 's';
273                }
274
275                echo ')'.PHP_EOL;
276                echo str_repeat('-', (strlen($lastStandard.$lastCount) + 10));
277                echo PHP_EOL;
278                echo $sniffList;
279
280                $lastStandard = $currentStandard;
281                $lastCount    = 0;
282
283                if ($currentStandard === null) {
284                    break;
285                }
286
287                ob_start();
288            }//end if
289
290            echo '  '.$sniff.PHP_EOL;
291            $lastCount++;
292        }//end foreach
293
294    }//end explain()
295
296
297    /**
298     * Processes a single ruleset and returns a list of the sniffs it represents.
299     *
300     * Rules founds within the ruleset are processed immediately, but sniff classes
301     * are not registered by this method.
302     *
303     * @param string $rulesetPath The path to a ruleset XML file.
304     * @param int    $depth       How many nested processing steps we are in. This
305     *                            is only used for debug output.
306     *
307     * @return string[]
308     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException - If the ruleset path is invalid.
309     *                                                      - If a specified autoload file could not be found.
310     */
311    public function processRuleset($rulesetPath, $depth=0)
312    {
313        $rulesetPath = Util\Common::realpath($rulesetPath);
314        if (PHP_CODESNIFFER_VERBOSITY > 1) {
315            echo str_repeat("\t", $depth);
316            echo 'Processing ruleset '.Util\Common::stripBasepath($rulesetPath, $this->config->basepath).PHP_EOL;
317        }
318
319        libxml_use_internal_errors(true);
320        $ruleset = simplexml_load_string(file_get_contents($rulesetPath));
321        if ($ruleset === false) {
322            $errorMsg = "Ruleset $rulesetPath is not valid".PHP_EOL;
323            $errors   = libxml_get_errors();
324            foreach ($errors as $error) {
325                $errorMsg .= '- On line '.$error->line.', column '.$error->column.': '.$error->message;
326            }
327
328            libxml_clear_errors();
329            throw new RuntimeException($errorMsg);
330        }
331
332        libxml_use_internal_errors(false);
333
334        $ownSniffs      = [];
335        $includedSniffs = [];
336        $excludedSniffs = [];
337
338        $this->paths[]       = $rulesetPath;
339        $rulesetDir          = dirname($rulesetPath);
340        $this->rulesetDirs[] = $rulesetDir;
341
342        $sniffDir = $rulesetDir.DIRECTORY_SEPARATOR.'Sniffs';
343        if (is_dir($sniffDir) === true) {
344            if (PHP_CODESNIFFER_VERBOSITY > 1) {
345                echo str_repeat("\t", $depth);
346                echo "\tAdding sniff files from ".Util\Common::stripBasepath($sniffDir, $this->config->basepath).' directory'.PHP_EOL;
347            }
348
349            $ownSniffs = $this->expandSniffDirectory($sniffDir, $depth);
350        }
351
352        // Include custom autoloaders.
353        foreach ($ruleset->{'autoload'} as $autoload) {
354            if ($this->shouldProcessElement($autoload) === false) {
355                continue;
356            }
357
358            $autoloadPath = (string) $autoload;
359
360            // Try relative autoload paths first.
361            $relativePath = Util\Common::realPath(dirname($rulesetPath).DIRECTORY_SEPARATOR.$autoloadPath);
362
363            if ($relativePath !== false && is_file($relativePath) === true) {
364                $autoloadPath = $relativePath;
365            } else if (is_file($autoloadPath) === false) {
366                throw new RuntimeException('The specified autoload file "'.$autoload.'" does not exist');
367            }
368
369            include_once $autoloadPath;
370
371            if (PHP_CODESNIFFER_VERBOSITY > 1) {
372                echo str_repeat("\t", $depth);
373                echo "\t=> included autoloader $autoloadPath".PHP_EOL;
374            }
375        }//end foreach
376
377        // Process custom sniff config settings.
378        foreach ($ruleset->{'config'} as $config) {
379            if ($this->shouldProcessElement($config) === false) {
380                continue;
381            }
382
383            Config::setConfigData((string) $config['name'], (string) $config['value'], true);
384            if (PHP_CODESNIFFER_VERBOSITY > 1) {
385                echo str_repeat("\t", $depth);
386                echo "\t=> set config value ".(string) $config['name'].': '.(string) $config['value'].PHP_EOL;
387            }
388        }
389
390        foreach ($ruleset->rule as $rule) {
391            if (isset($rule['ref']) === false
392                || $this->shouldProcessElement($rule) === false
393            ) {
394                continue;
395            }
396
397            if (PHP_CODESNIFFER_VERBOSITY > 1) {
398                echo str_repeat("\t", $depth);
399                echo "\tProcessing rule \"".$rule['ref'].'"'.PHP_EOL;
400            }
401
402            $expandedSniffs = $this->expandRulesetReference((string) $rule['ref'], $rulesetDir, $depth);
403            $newSniffs      = array_diff($expandedSniffs, $includedSniffs);
404            $includedSniffs = array_merge($includedSniffs, $expandedSniffs);
405
406            $parts = explode('.', $rule['ref']);
407            if (count($parts) === 4
408                && $parts[0] !== ''
409                && $parts[1] !== ''
410                && $parts[2] !== ''
411            ) {
412                $sniffCode = $parts[0].'.'.$parts[1].'.'.$parts[2];
413                if (isset($this->ruleset[$sniffCode]['severity']) === true
414                    && $this->ruleset[$sniffCode]['severity'] === 0
415                ) {
416                    // This sniff code has already been turned off, but now
417                    // it is being explicitly included again, so turn it back on.
418                    $this->ruleset[(string) $rule['ref']]['severity'] = 5;
419                    if (PHP_CODESNIFFER_VERBOSITY > 1) {
420                        echo str_repeat("\t", $depth);
421                        echo "\t\t* disabling sniff exclusion for specific message code *".PHP_EOL;
422                        echo str_repeat("\t", $depth);
423                        echo "\t\t=> severity set to 5".PHP_EOL;
424                    }
425                } else if (empty($newSniffs) === false) {
426                    $newSniff = $newSniffs[0];
427                    if (in_array($newSniff, $ownSniffs, true) === false) {
428                        // Including a sniff that hasn't been included higher up, but
429                        // only including a single message from it. So turn off all messages in
430                        // the sniff, except this one.
431                        $this->ruleset[$sniffCode]['severity']            = 0;
432                        $this->ruleset[(string) $rule['ref']]['severity'] = 5;
433                        if (PHP_CODESNIFFER_VERBOSITY > 1) {
434                            echo str_repeat("\t", $depth);
435                            echo "\t\tExcluding sniff \"".$sniffCode.'" except for "'.$parts[3].'"'.PHP_EOL;
436                        }
437                    }
438                }//end if
439            }//end if
440
441            if (isset($rule->exclude) === true) {
442                foreach ($rule->exclude as $exclude) {
443                    if (isset($exclude['name']) === false) {
444                        if (PHP_CODESNIFFER_VERBOSITY > 1) {
445                            echo str_repeat("\t", $depth);
446                            echo "\t\t* ignoring empty exclude rule *".PHP_EOL;
447                            echo "\t\t\t=> ".$exclude->asXML().PHP_EOL;
448                        }
449
450                        continue;
451                    }
452
453                    if ($this->shouldProcessElement($exclude) === false) {
454                        continue;
455                    }
456
457                    if (PHP_CODESNIFFER_VERBOSITY > 1) {
458                        echo str_repeat("\t", $depth);
459                        echo "\t\tExcluding rule \"".$exclude['name'].'"'.PHP_EOL;
460                    }
461
462                    // Check if a single code is being excluded, which is a shortcut
463                    // for setting the severity of the message to 0.
464                    $parts = explode('.', $exclude['name']);
465                    if (count($parts) === 4) {
466                        $this->ruleset[(string) $exclude['name']]['severity'] = 0;
467                        if (PHP_CODESNIFFER_VERBOSITY > 1) {
468                            echo str_repeat("\t", $depth);
469                            echo "\t\t=> severity set to 0".PHP_EOL;
470                        }
471                    } else {
472                        $excludedSniffs = array_merge(
473                            $excludedSniffs,
474                            $this->expandRulesetReference((string) $exclude['name'], $rulesetDir, ($depth + 1))
475                        );
476                    }
477                }//end foreach
478            }//end if
479
480            $this->processRule($rule, $newSniffs, $depth);
481        }//end foreach
482
483        // Process custom command line arguments.
484        $cliArgs = [];
485        foreach ($ruleset->{'arg'} as $arg) {
486            if ($this->shouldProcessElement($arg) === false) {
487                continue;
488            }
489
490            if (isset($arg['name']) === true) {
491                $argString = '--'.(string) $arg['name'];
492                if (isset($arg['value']) === true) {
493                    $argString .= '='.(string) $arg['value'];
494                }
495            } else {
496                $argString = '-'.(string) $arg['value'];
497            }
498
499            $cliArgs[] = $argString;
500
501            if (PHP_CODESNIFFER_VERBOSITY > 1) {
502                echo str_repeat("\t", $depth);
503                echo "\t=> set command line value $argString".PHP_EOL;
504            }
505        }//end foreach
506
507        // Set custom php ini values as CLI args.
508        foreach ($ruleset->{'ini'} as $arg) {
509            if ($this->shouldProcessElement($arg) === false) {
510                continue;
511            }
512
513            if (isset($arg['name']) === false) {
514                continue;
515            }
516
517            $name      = (string) $arg['name'];
518            $argString = $name;
519            if (isset($arg['value']) === true) {
520                $value      = (string) $arg['value'];
521                $argString .= "=$value";
522            } else {
523                $value = 'true';
524            }
525
526            $cliArgs[] = '-d';
527            $cliArgs[] = $argString;
528
529            if (PHP_CODESNIFFER_VERBOSITY > 1) {
530                echo str_repeat("\t", $depth);
531                echo "\t=> set PHP ini value $name to $value".PHP_EOL;
532            }
533        }//end foreach
534
535        if (empty($this->config->files) === true) {
536            // Process hard-coded file paths.
537            foreach ($ruleset->{'file'} as $file) {
538                $file      = (string) $file;
539                $cliArgs[] = $file;
540                if (PHP_CODESNIFFER_VERBOSITY > 1) {
541                    echo str_repeat("\t", $depth);
542                    echo "\t=> added \"$file\" to the file list".PHP_EOL;
543                }
544            }
545        }
546
547        if (empty($cliArgs) === false) {
548            // Change the directory so all relative paths are worked
549            // out based on the location of the ruleset instead of
550            // the location of the user.
551            $inPhar = Util\Common::isPharFile($rulesetDir);
552            if ($inPhar === false) {
553                $currentDir = getcwd();
554                chdir($rulesetDir);
555            }
556
557            $this->config->setCommandLineValues($cliArgs);
558
559            if ($inPhar === false) {
560                chdir($currentDir);
561            }
562        }
563
564        // Process custom ignore pattern rules.
565        foreach ($ruleset->{'exclude-pattern'} as $pattern) {
566            if ($this->shouldProcessElement($pattern) === false) {
567                continue;
568            }
569
570            if (isset($pattern['type']) === false) {
571                $pattern['type'] = 'absolute';
572            }
573
574            $this->ignorePatterns[(string) $pattern] = (string) $pattern['type'];
575            if (PHP_CODESNIFFER_VERBOSITY > 1) {
576                echo str_repeat("\t", $depth);
577                echo "\t=> added global ".(string) $pattern['type'].' ignore pattern: '.(string) $pattern.PHP_EOL;
578            }
579        }
580
581        $includedSniffs = array_unique(array_merge($ownSniffs, $includedSniffs));
582        $excludedSniffs = array_unique($excludedSniffs);
583
584        if (PHP_CODESNIFFER_VERBOSITY > 1) {
585            $included = count($includedSniffs);
586            $excluded = count($excludedSniffs);
587            echo str_repeat("\t", $depth);
588            echo "=> Ruleset processing complete; included $included sniffs and excluded $excluded".PHP_EOL;
589        }
590
591        // Merge our own sniff list with our externally included
592        // sniff list, but filter out any excluded sniffs.
593        $files = [];
594        foreach ($includedSniffs as $sniff) {
595            if (in_array($sniff, $excludedSniffs, true) === true) {
596                continue;
597            } else {
598                $files[] = Util\Common::realpath($sniff);
599            }
600        }
601
602        return $files;
603
604    }//end processRuleset()
605
606
607    /**
608     * Expands a directory into a list of sniff files within.
609     *
610     * @param string $directory The path to a directory.
611     * @param int    $depth     How many nested processing steps we are in. This
612     *                          is only used for debug output.
613     *
614     * @return array
615     */
616    private function expandSniffDirectory($directory, $depth=0)
617    {
618        $sniffs = [];
619
620        $rdi = new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);
621        $di  = new \RecursiveIteratorIterator($rdi, 0, \RecursiveIteratorIterator::CATCH_GET_CHILD);
622
623        $dirLen = strlen($directory);
624
625        foreach ($di as $file) {
626            $filename = $file->getFilename();
627
628            // Skip hidden files.
629            if (substr($filename, 0, 1) === '.') {
630                continue;
631            }
632
633            // We are only interested in PHP and sniff files.
634            $fileParts = explode('.', $filename);
635            if (array_pop($fileParts) !== 'php') {
636                continue;
637            }
638
639            $basename = basename($filename, '.php');
640            if (substr($basename, -5) !== 'Sniff') {
641                continue;
642            }
643
644            $path = $file->getPathname();
645
646            // Skip files in hidden directories within the Sniffs directory of this
647            // standard. We use the offset with strpos() to allow hidden directories
648            // before, valid example:
649            // /home/foo/.composer/vendor/squiz/custom_tool/MyStandard/Sniffs/...
650            if (strpos($path, DIRECTORY_SEPARATOR.'.', $dirLen) !== false) {
651                continue;
652            }
653
654            if (PHP_CODESNIFFER_VERBOSITY > 1) {
655                echo str_repeat("\t", $depth);
656                echo "\t\t=> ".Util\Common::stripBasepath($path, $this->config->basepath).PHP_EOL;
657            }
658
659            $sniffs[] = $path;
660        }//end foreach
661
662        return $sniffs;
663
664    }//end expandSniffDirectory()
665
666
667    /**
668     * Expands a ruleset reference into a list of sniff files.
669     *
670     * @param string $ref        The reference from the ruleset XML file.
671     * @param string $rulesetDir The directory of the ruleset XML file, used to
672     *                           evaluate relative paths.
673     * @param int    $depth      How many nested processing steps we are in. This
674     *                           is only used for debug output.
675     *
676     * @return array
677     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If the reference is invalid.
678     */
679    private function expandRulesetReference($ref, $rulesetDir, $depth=0)
680    {
681        // Ignore internal sniffs codes as they are used to only
682        // hide and change internal messages.
683        if (substr($ref, 0, 9) === 'Internal.') {
684            if (PHP_CODESNIFFER_VERBOSITY > 1) {
685                echo str_repeat("\t", $depth);
686                echo "\t\t* ignoring internal sniff code *".PHP_EOL;
687            }
688
689            return [];
690        }
691
692        // As sniffs can't begin with a full stop, assume references in
693        // this format are relative paths and attempt to convert them
694        // to absolute paths. If this fails, let the reference run through
695        // the normal checks and have it fail as normal.
696        if (substr($ref, 0, 1) === '.') {
697            $realpath = Util\Common::realpath($rulesetDir.'/'.$ref);
698            if ($realpath !== false) {
699                $ref = $realpath;
700                if (PHP_CODESNIFFER_VERBOSITY > 1) {
701                    echo str_repeat("\t", $depth);
702                    echo "\t\t=> ".Util\Common::stripBasepath($ref, $this->config->basepath).PHP_EOL;
703                }
704            }
705        }
706
707        // As sniffs can't begin with a tilde, assume references in
708        // this format are relative to the user's home directory.
709        if (substr($ref, 0, 2) === '~/') {
710            $realpath = Util\Common::realpath($ref);
711            if ($realpath !== false) {
712                $ref = $realpath;
713                if (PHP_CODESNIFFER_VERBOSITY > 1) {
714                    echo str_repeat("\t", $depth);
715                    echo "\t\t=> ".Util\Common::stripBasepath($ref, $this->config->basepath).PHP_EOL;
716                }
717            }
718        }
719
720        if (is_file($ref) === true) {
721            if (substr($ref, -9) === 'Sniff.php') {
722                // A single external sniff.
723                $this->rulesetDirs[] = dirname(dirname(dirname($ref)));
724                return [$ref];
725            }
726        } else {
727            // See if this is a whole standard being referenced.
728            $path = Util\Standards::getInstalledStandardPath($ref);
729            if ($path !== null && Util\Common::isPharFile($path) === true && strpos($path, 'ruleset.xml') === false) {
730                // If the ruleset exists inside the phar file, use it.
731                if (file_exists($path.DIRECTORY_SEPARATOR.'ruleset.xml') === true) {
732                    $path .= DIRECTORY_SEPARATOR.'ruleset.xml';
733                } else {
734                    $path = null;
735                }
736            }
737
738            if ($path !== null) {
739                $ref = $path;
740                if (PHP_CODESNIFFER_VERBOSITY > 1) {
741                    echo str_repeat("\t", $depth);
742                    echo "\t\t=> ".Util\Common::stripBasepath($ref, $this->config->basepath).PHP_EOL;
743                }
744            } else if (is_dir($ref) === false) {
745                // Work out the sniff path.
746                $sepPos = strpos($ref, DIRECTORY_SEPARATOR);
747                if ($sepPos !== false) {
748                    $stdName = substr($ref, 0, $sepPos);
749                    $path    = substr($ref, $sepPos);
750                } else {
751                    $parts   = explode('.', $ref);
752                    $stdName = $parts[0];
753                    if (count($parts) === 1) {
754                        // A whole standard?
755                        $path = '';
756                    } else if (count($parts) === 2) {
757                        // A directory of sniffs?
758                        $path = DIRECTORY_SEPARATOR.'Sniffs'.DIRECTORY_SEPARATOR.$parts[1];
759                    } else {
760                        // A single sniff?
761                        $path = DIRECTORY_SEPARATOR.'Sniffs'.DIRECTORY_SEPARATOR.$parts[1].DIRECTORY_SEPARATOR.$parts[2].'Sniff.php';
762                    }
763                }
764
765                $newRef  = false;
766                $stdPath = Util\Standards::getInstalledStandardPath($stdName);
767                if ($stdPath !== null && $path !== '') {
768                    if (Util\Common::isPharFile($stdPath) === true
769                        && strpos($stdPath, 'ruleset.xml') === false
770                    ) {
771                        // Phar files can only return the directory,
772                        // since ruleset can be omitted if building one standard.
773                        $newRef = Util\Common::realpath($stdPath.$path);
774                    } else {
775                        $newRef = Util\Common::realpath(dirname($stdPath).$path);
776                    }
777                }
778
779                if ($newRef === false) {
780                    // The sniff is not locally installed, so check if it is being
781                    // referenced as a remote sniff outside the install. We do this
782                    // by looking through all directories where we have found ruleset
783                    // files before, looking for ones for this particular standard,
784                    // and seeing if it is in there.
785                    foreach ($this->rulesetDirs as $dir) {
786                        if (strtolower(basename($dir)) !== strtolower($stdName)) {
787                            continue;
788                        }
789
790                        $newRef = Util\Common::realpath($dir.$path);
791
792                        if ($newRef !== false) {
793                            $ref = $newRef;
794                        }
795                    }
796                } else {
797                    $ref = $newRef;
798                }
799
800                if (PHP_CODESNIFFER_VERBOSITY > 1) {
801                    echo str_repeat("\t", $depth);
802                    echo "\t\t=> ".Util\Common::stripBasepath($ref, $this->config->basepath).PHP_EOL;
803                }
804            }//end if
805        }//end if
806
807        if (is_dir($ref) === true) {
808            if (is_file($ref.DIRECTORY_SEPARATOR.'ruleset.xml') === true) {
809                // We are referencing an external coding standard.
810                if (PHP_CODESNIFFER_VERBOSITY > 1) {
811                    echo str_repeat("\t", $depth);
812                    echo "\t\t* rule is referencing a standard using directory name; processing *".PHP_EOL;
813                }
814
815                return $this->processRuleset($ref.DIRECTORY_SEPARATOR.'ruleset.xml', ($depth + 2));
816            } else {
817                // We are referencing a whole directory of sniffs.
818                if (PHP_CODESNIFFER_VERBOSITY > 1) {
819                    echo str_repeat("\t", $depth);
820                    echo "\t\t* rule is referencing a directory of sniffs *".PHP_EOL;
821                    echo str_repeat("\t", $depth);
822                    echo "\t\tAdding sniff files from directory".PHP_EOL;
823                }
824
825                return $this->expandSniffDirectory($ref, ($depth + 1));
826            }
827        } else {
828            if (is_file($ref) === false) {
829                $error = "Referenced sniff \"$ref\" does not exist";
830                throw new RuntimeException($error);
831            }
832
833            if (substr($ref, -9) === 'Sniff.php') {
834                // A single sniff.
835                return [$ref];
836            } else {
837                // Assume an external ruleset.xml file.
838                if (PHP_CODESNIFFER_VERBOSITY > 1) {
839                    echo str_repeat("\t", $depth);
840                    echo "\t\t* rule is referencing a standard using ruleset path; processing *".PHP_EOL;
841                }
842
843                return $this->processRuleset($ref, ($depth + 2));
844            }
845        }//end if
846
847    }//end expandRulesetReference()
848
849
850    /**
851     * Processes a rule from a ruleset XML file, overriding built-in defaults.
852     *
853     * @param \SimpleXMLElement $rule      The rule object from a ruleset XML file.
854     * @param string[]          $newSniffs An array of sniffs that got included by this rule.
855     * @param int               $depth     How many nested processing steps we are in.
856     *                                     This is only used for debug output.
857     *
858     * @return void
859     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If rule settings are invalid.
860     */
861    private function processRule($rule, $newSniffs, $depth=0)
862    {
863        $ref  = (string) $rule['ref'];
864        $todo = [$ref];
865
866        $parts      = explode('.', $ref);
867        $partsCount = count($parts);
868        if ($partsCount <= 2
869            || $partsCount > count(array_filter($parts))
870            || in_array($ref, $newSniffs) === true
871        ) {
872            // We are processing a standard, a category of sniffs or a relative path inclusion.
873            foreach ($newSniffs as $sniffFile) {
874                $parts = explode(DIRECTORY_SEPARATOR, $sniffFile);
875                if (count($parts) === 1 && DIRECTORY_SEPARATOR === '\\') {
876                    // Path using forward slashes while running on Windows.
877                    $parts = explode('/', $sniffFile);
878                }
879
880                $sniffName     = array_pop($parts);
881                $sniffCategory = array_pop($parts);
882                array_pop($parts);
883                $sniffStandard = array_pop($parts);
884                $todo[]        = $sniffStandard.'.'.$sniffCategory.'.'.substr($sniffName, 0, -9);
885            }
886        }
887
888        foreach ($todo as $code) {
889            // Custom severity.
890            if (isset($rule->severity) === true
891                && $this->shouldProcessElement($rule->severity) === true
892            ) {
893                if (isset($this->ruleset[$code]) === false) {
894                    $this->ruleset[$code] = [];
895                }
896
897                $this->ruleset[$code]['severity'] = (int) $rule->severity;
898                if (PHP_CODESNIFFER_VERBOSITY > 1) {
899                    echo str_repeat("\t", $depth);
900                    echo "\t\t=> severity set to ".(int) $rule->severity;
901                    if ($code !== $ref) {
902                        echo " for $code";
903                    }
904
905                    echo PHP_EOL;
906                }
907            }
908
909            // Custom message type.
910            if (isset($rule->type) === true
911                && $this->shouldProcessElement($rule->type) === true
912            ) {
913                if (isset($this->ruleset[$code]) === false) {
914                    $this->ruleset[$code] = [];
915                }
916
917                $type = strtolower((string) $rule->type);
918                if ($type !== 'error' && $type !== 'warning') {
919                    throw new RuntimeException("Message type \"$type\" is invalid; must be \"error\" or \"warning\"");
920                }
921
922                $this->ruleset[$code]['type'] = $type;
923                if (PHP_CODESNIFFER_VERBOSITY > 1) {
924                    echo str_repeat("\t", $depth);
925                    echo "\t\t=> message type set to ".(string) $rule->type;
926                    if ($code !== $ref) {
927                        echo " for $code";
928                    }
929
930                    echo PHP_EOL;
931                }
932            }//end if
933
934            // Custom message.
935            if (isset($rule->message) === true
936                && $this->shouldProcessElement($rule->message) === true
937            ) {
938                if (isset($this->ruleset[$code]) === false) {
939                    $this->ruleset[$code] = [];
940                }
941
942                $this->ruleset[$code]['message'] = (string) $rule->message;
943                if (PHP_CODESNIFFER_VERBOSITY > 1) {
944                    echo str_repeat("\t", $depth);
945                    echo "\t\t=> message set to ".(string) $rule->message;
946                    if ($code !== $ref) {
947                        echo " for $code";
948                    }
949
950                    echo PHP_EOL;
951                }
952            }
953
954            // Custom properties.
955            if (isset($rule->properties) === true
956                && $this->shouldProcessElement($rule->properties) === true
957            ) {
958                foreach ($rule->properties->property as $prop) {
959                    if ($this->shouldProcessElement($prop) === false) {
960                        continue;
961                    }
962
963                    if (isset($this->ruleset[$code]) === false) {
964                        $this->ruleset[$code] = [
965                            'properties' => [],
966                        ];
967                    } else if (isset($this->ruleset[$code]['properties']) === false) {
968                        $this->ruleset[$code]['properties'] = [];
969                    }
970
971                    $name = (string) $prop['name'];
972                    if (isset($prop['type']) === true
973                        && (string) $prop['type'] === 'array'
974                    ) {
975                        $values = [];
976                        if (isset($prop['extend']) === true
977                            && (string) $prop['extend'] === 'true'
978                            && isset($this->ruleset[$code]['properties'][$name]) === true
979                        ) {
980                            $values = $this->ruleset[$code]['properties'][$name];
981                        }
982
983                        if (isset($prop->element) === true) {
984                            $printValue = '';
985                            foreach ($prop->element as $element) {
986                                if ($this->shouldProcessElement($element) === false) {
987                                    continue;
988                                }
989
990                                $value = (string) $element['value'];
991                                if (isset($element['key']) === true) {
992                                    $key          = (string) $element['key'];
993                                    $values[$key] = $value;
994                                    $printValue  .= $key.'=>'.$value.',';
995                                } else {
996                                    $values[]    = $value;
997                                    $printValue .= $value.',';
998                                }
999                            }
1000
1001                            $printValue = rtrim($printValue, ',');
1002                        } else {
1003                            $value      = (string) $prop['value'];
1004                            $printValue = $value;
1005                            foreach (explode(',', $value) as $val) {
1006                                list($k, $v) = explode('=>', $val.'=>');
1007                                if ($v !== '') {
1008                                    $values[trim($k)] = trim($v);
1009                                } else {
1010                                    $values[] = trim($k);
1011                                }
1012                            }
1013                        }//end if
1014
1015                        $this->ruleset[$code]['properties'][$name] = $values;
1016                        if (PHP_CODESNIFFER_VERBOSITY > 1) {
1017                            echo str_repeat("\t", $depth);
1018                            echo "\t\t=> array property \"$name\" set to \"$printValue\"";
1019                            if ($code !== $ref) {
1020                                echo " for $code";
1021                            }
1022
1023                            echo PHP_EOL;
1024                        }
1025                    } else {
1026                        $this->ruleset[$code]['properties'][$name] = (string) $prop['value'];
1027                        if (PHP_CODESNIFFER_VERBOSITY > 1) {
1028                            echo str_repeat("\t", $depth);
1029                            echo "\t\t=> property \"$name\" set to \"".(string) $prop['value'].'"';
1030                            if ($code !== $ref) {
1031                                echo " for $code";
1032                            }
1033
1034                            echo PHP_EOL;
1035                        }
1036                    }//end if
1037                }//end foreach
1038            }//end if
1039
1040            // Ignore patterns.
1041            foreach ($rule->{'exclude-pattern'} as $pattern) {
1042                if ($this->shouldProcessElement($pattern) === false) {
1043                    continue;
1044                }
1045
1046                if (isset($this->ignorePatterns[$code]) === false) {
1047                    $this->ignorePatterns[$code] = [];
1048                }
1049
1050                if (isset($pattern['type']) === false) {
1051                    $pattern['type'] = 'absolute';
1052                }
1053
1054                $this->ignorePatterns[$code][(string) $pattern] = (string) $pattern['type'];
1055                if (PHP_CODESNIFFER_VERBOSITY > 1) {
1056                    echo str_repeat("\t", $depth);
1057                    echo "\t\t=> added rule-specific ".(string) $pattern['type'].' ignore pattern';
1058                    if ($code !== $ref) {
1059                        echo " for $code";
1060                    }
1061
1062                    echo ': '.(string) $pattern.PHP_EOL;
1063                }
1064            }//end foreach
1065
1066            // Include patterns.
1067            foreach ($rule->{'include-pattern'} as $pattern) {
1068                if ($this->shouldProcessElement($pattern) === false) {
1069                    continue;
1070                }
1071
1072                if (isset($this->includePatterns[$code]) === false) {
1073                    $this->includePatterns[$code] = [];
1074                }
1075
1076                if (isset($pattern['type']) === false) {
1077                    $pattern['type'] = 'absolute';
1078                }
1079
1080                $this->includePatterns[$code][(string) $pattern] = (string) $pattern['type'];
1081                if (PHP_CODESNIFFER_VERBOSITY > 1) {
1082                    echo str_repeat("\t", $depth);
1083                    echo "\t\t=> added rule-specific ".(string) $pattern['type'].' include pattern';
1084                    if ($code !== $ref) {
1085                        echo " for $code";
1086                    }
1087
1088                    echo ': '.(string) $pattern.PHP_EOL;
1089                }
1090            }//end foreach
1091        }//end foreach
1092
1093    }//end processRule()
1094
1095
1096    /**
1097     * Determine if an element should be processed or ignored.
1098     *
1099     * @param \SimpleXMLElement $element An object from a ruleset XML file.
1100     *
1101     * @return bool
1102     */
1103    private function shouldProcessElement($element)
1104    {
1105        if (isset($element['phpcbf-only']) === false
1106            && isset($element['phpcs-only']) === false
1107        ) {
1108            // No exceptions are being made.
1109            return true;
1110        }
1111
1112        if (PHP_CODESNIFFER_CBF === true
1113            && isset($element['phpcbf-only']) === true
1114            && (string) $element['phpcbf-only'] === 'true'
1115        ) {
1116            return true;
1117        }
1118
1119        if (PHP_CODESNIFFER_CBF === false
1120            && isset($element['phpcs-only']) === true
1121            && (string) $element['phpcs-only'] === 'true'
1122        ) {
1123            return true;
1124        }
1125
1126        return false;
1127
1128    }//end shouldProcessElement()
1129
1130
1131    /**
1132     * Loads and stores sniffs objects used for sniffing files.
1133     *
1134     * @param array $files        Paths to the sniff files to register.
1135     * @param array $restrictions The sniff class names to restrict the allowed
1136     *                            listeners to.
1137     * @param array $exclusions   The sniff class names to exclude from the
1138     *                            listeners list.
1139     *
1140     * @return void
1141     */
1142    public function registerSniffs($files, $restrictions, $exclusions)
1143    {
1144        $listeners = [];
1145
1146        foreach ($files as $file) {
1147            // Work out where the position of /StandardName/Sniffs/... is
1148            // so we can determine what the class will be called.
1149            $sniffPos = strrpos($file, DIRECTORY_SEPARATOR.'Sniffs'.DIRECTORY_SEPARATOR);
1150            if ($sniffPos === false) {
1151                continue;
1152            }
1153
1154            $slashPos = strrpos(substr($file, 0, $sniffPos), DIRECTORY_SEPARATOR);
1155            if ($slashPos === false) {
1156                continue;
1157            }
1158
1159            $className   = Autoload::loadFile($file);
1160            $compareName = Util\Common::cleanSniffClass($className);
1161
1162            // If they have specified a list of sniffs to restrict to, check
1163            // to see if this sniff is allowed.
1164            if (empty($restrictions) === false
1165                && isset($restrictions[$compareName]) === false
1166            ) {
1167                continue;
1168            }
1169
1170            // If they have specified a list of sniffs to exclude, check
1171            // to see if this sniff is allowed.
1172            if (empty($exclusions) === false
1173                && isset($exclusions[$compareName]) === true
1174            ) {
1175                continue;
1176            }
1177
1178            // Skip abstract classes.
1179            $reflection = new \ReflectionClass($className);
1180            if ($reflection->isAbstract() === true) {
1181                continue;
1182            }
1183
1184            $listeners[$className] = $className;
1185
1186            if (PHP_CODESNIFFER_VERBOSITY > 2) {
1187                echo "Registered $className".PHP_EOL;
1188            }
1189        }//end foreach
1190
1191        $this->sniffs = $listeners;
1192
1193    }//end registerSniffs()
1194
1195
1196    /**
1197     * Populates the array of PHP_CodeSniffer_Sniff objects for this file.
1198     *
1199     * @return void
1200     * @throws \PHP_CodeSniffer\Exceptions\RuntimeException If sniff registration fails.
1201     */
1202    public function populateTokenListeners()
1203    {
1204        // Construct a list of listeners indexed by token being listened for.
1205        $this->tokenListeners = [];
1206
1207        foreach ($this->sniffs as $sniffClass => $sniffObject) {
1208            $this->sniffs[$sniffClass] = null;
1209            $this->sniffs[$sniffClass] = new $sniffClass();
1210
1211            $sniffCode = Util\Common::getSniffCode($sniffClass);
1212            $this->sniffCodes[$sniffCode] = $sniffClass;
1213
1214            // Set custom properties.
1215            if (isset($this->ruleset[$sniffCode]['properties']) === true) {
1216                foreach ($this->ruleset[$sniffCode]['properties'] as $name => $value) {
1217                    $this->setSniffProperty($sniffClass, $name, $value);
1218                }
1219            }
1220
1221            $tokenizers = [];
1222            $vars       = get_class_vars($sniffClass);
1223            if (isset($vars['supportedTokenizers']) === true) {
1224                foreach ($vars['supportedTokenizers'] as $tokenizer) {
1225                    $tokenizers[$tokenizer] = $tokenizer;
1226                }
1227            } else {
1228                $tokenizers = ['PHP' => 'PHP'];
1229            }
1230
1231            $tokens = $this->sniffs[$sniffClass]->register();
1232            if (is_array($tokens) === false) {
1233                $msg = "Sniff $sniffClass register() method must return an array";
1234                throw new RuntimeException($msg);
1235            }
1236
1237            $ignorePatterns = [];
1238            $patterns       = $this->getIgnorePatterns($sniffCode);
1239            foreach ($patterns as $pattern => $type) {
1240                $replacements = [
1241                    '\\,' => ',',
1242                    '*'   => '.*',
1243                ];
1244
1245                $ignorePatterns[] = strtr($pattern, $replacements);
1246            }
1247
1248            $includePatterns = [];
1249            $patterns        = $this->getIncludePatterns($sniffCode);
1250            foreach ($patterns as $pattern => $type) {
1251                $replacements = [
1252                    '\\,' => ',',
1253                    '*'   => '.*',
1254                ];
1255
1256                $includePatterns[] = strtr($pattern, $replacements);
1257            }
1258
1259            foreach ($tokens as $token) {
1260                if (isset($this->tokenListeners[$token]) === false) {
1261                    $this->tokenListeners[$token] = [];
1262                }
1263
1264                if (isset($this->tokenListeners[$token][$sniffClass]) === false) {
1265                    $this->tokenListeners[$token][$sniffClass] = [
1266                        'class'      => $sniffClass,
1267                        'source'     => $sniffCode,
1268                        'tokenizers' => $tokenizers,
1269                        'ignore'     => $ignorePatterns,
1270                        'include'    => $includePatterns,
1271                    ];
1272                }
1273            }
1274        }//end foreach
1275
1276    }//end populateTokenListeners()
1277
1278
1279    /**
1280     * Set a single property for a sniff.
1281     *
1282     * @param string $sniffClass The class name of the sniff.
1283     * @param string $name       The name of the property to change.
1284     * @param string $value      The new value of the property.
1285     *
1286     * @return void
1287     */
1288    public function setSniffProperty($sniffClass, $name, $value)
1289    {
1290        // Setting a property for a sniff we are not using.
1291        if (isset($this->sniffs[$sniffClass]) === false) {
1292            return;
1293        }
1294
1295        $name = trim($name);
1296        if (is_string($value) === true) {
1297            $value = trim($value);
1298        }
1299
1300        if ($value === '') {
1301            $value = null;
1302        }
1303
1304        // Special case for booleans.
1305        if ($value === 'true') {
1306            $value = true;
1307        } else if ($value === 'false') {
1308            $value = false;
1309        } else if (substr($name, -2) === '[]') {
1310            $name   = substr($name, 0, -2);
1311            $values = [];
1312            if ($value !== null) {
1313                foreach (explode(',', $value) as $val) {
1314                    list($k, $v) = explode('=>', $val.'=>');
1315                    if ($v !== '') {
1316                        $values[trim($k)] = trim($v);
1317                    } else {
1318                        $values[] = trim($k);
1319                    }
1320                }
1321            }
1322
1323            $value = $values;
1324        }
1325
1326        $this->sniffs[$sniffClass]->$name = $value;
1327
1328    }//end setSniffProperty()
1329
1330
1331    /**
1332     * Gets the array of ignore patterns.
1333     *
1334     * Optionally takes a listener to get ignore patterns specified
1335     * for that sniff only.
1336     *
1337     * @param string $listener The listener to get patterns for. If NULL, all
1338     *                         patterns are returned.
1339     *
1340     * @return array
1341     */
1342    public function getIgnorePatterns($listener=null)
1343    {
1344        if ($listener === null) {
1345            return $this->ignorePatterns;
1346        }
1347
1348        if (isset($this->ignorePatterns[$listener]) === true) {
1349            return $this->ignorePatterns[$listener];
1350        }
1351
1352        return [];
1353
1354    }//end getIgnorePatterns()
1355
1356
1357    /**
1358     * Gets the array of include patterns.
1359     *
1360     * Optionally takes a listener to get include patterns specified
1361     * for that sniff only.
1362     *
1363     * @param string $listener The listener to get patterns for. If NULL, all
1364     *                         patterns are returned.
1365     *
1366     * @return array
1367     */
1368    public function getIncludePatterns($listener=null)
1369    {
1370        if ($listener === null) {
1371            return $this->includePatterns;
1372        }
1373
1374        if (isset($this->includePatterns[$listener]) === true) {
1375            return $this->includePatterns[$listener];
1376        }
1377
1378        return [];
1379
1380    }//end getIncludePatterns()
1381
1382
1383}//end class
1384