├── bin └── phpcstd ├── .gitignore ├── src ├── Formatter │ ├── Formatter.php │ ├── File.php │ ├── Violation.php │ ├── Result.php │ ├── GithubActionFormatter.php │ └── ConsoleFormatter.php ├── Tools │ ├── PhpParallelLint │ │ ├── Manager.php │ │ ├── PhpParallelLint.php │ │ └── ContextOutput.php │ ├── PhpMessDetector.php │ ├── Phan.php │ ├── Rector.php │ ├── Deptrac.php │ ├── ComposerNormalize.php │ ├── PhpCodeSniffer.php │ ├── Psalm.php │ ├── Phpstan.php │ ├── EasyCodingStandard.php │ └── Tool.php ├── Context.php ├── init.php ├── main.php ├── Cli.php ├── DiffViolation.php ├── Config.php └── Commands │ └── RunCommand.php ├── deptrac.yaml ├── .gitattributes ├── LICENSE ├── phpstan-baseline.neon ├── .phpcstd.dist.ini ├── ecs.php ├── composer.json └── README.md /bin/phpcstd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | violations = array_merge($this->violations, $file->violations); 20 | 21 | return $this; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Formatter/Violation.php: -------------------------------------------------------------------------------- 1 | output = new ContextOutput(new NullWriter()); 19 | $this->output->setContext($context); 20 | $this->output->setOutput($output); 21 | } 22 | 23 | protected function getDefaultOutput(Settings $settings): Output 24 | { 25 | return $this->output; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | config = $config; 35 | 36 | $this->result = new Result(); 37 | } 38 | 39 | public function addResult(Result $result): void 40 | { 41 | $this->result->add($result); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Tools/PhpParallelLint/PhpParallelLint.php: -------------------------------------------------------------------------------- 1 | config->getPart($this->name); 21 | 22 | $manager = new Manager($context, $this->output); 23 | 24 | $settings = new Settings(); 25 | $settings->addPaths($context->files); 26 | $settings->parallelJobs = (int) ($config['processes'] ?? 24); 27 | 28 | $result = $manager->run($settings); 29 | 30 | return ! $result->hasError(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Formatter/Result.php: -------------------------------------------------------------------------------- 1 | |File[] */ 10 | public $files = []; 11 | 12 | /** 13 | * @return static 14 | */ 15 | public function add(self $result): self 16 | { 17 | foreach ($result->files as $filename => $file) { 18 | if ($file->violations === []) { 19 | continue; 20 | } 21 | 22 | $filename = self::removeRootPath($filename); 23 | 24 | if (array_key_exists($filename, $this->files)) { 25 | $this->files[$filename]->add($file); 26 | continue; 27 | } 28 | 29 | $this->files[$filename] = $file; 30 | } 31 | 32 | return $this; 33 | } 34 | 35 | private static function removeRootPath(string $path): string 36 | { 37 | if (strpos($path, PHPCSTD_ROOT) === 0) { 38 | return substr($path, strlen(PHPCSTD_ROOT)); 39 | } 40 | 41 | return $path; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/init.php: -------------------------------------------------------------------------------- 1 | check(); 41 | })(); 42 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Parameter \\#1 \\$string of function trim expects string, string\\|null given\\.$#" 5 | count: 1 6 | path: src/Tools/ComposerNormalize.php 7 | 8 | - 9 | message: "#^Parameter \\#1 \\$data of function simplexml_load_string expects string, string\\|false given\\.$#" 10 | count: 1 11 | path: src/Tools/Deptrac.php 12 | 13 | - 14 | message: "#^Method Spaceemotion\\\\PhpCodingStandard\\\\Tools\\\\Phpstan\\:\\:getJsonLine\\(\\) has parameter \\$output with no value type specified in iterable type array\\.$#" 15 | count: 1 16 | path: src/Tools/Phpstan.php 17 | 18 | - 19 | message: "#^Parameter \\#1 \\$string of function strtolower expects string, string\\|null given\\.$#" 20 | count: 1 21 | path: src/Tools/Rector.php 22 | 23 | - 24 | message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#" 25 | count: 1 26 | path: src/Tools/Rector.php 27 | 28 | - 29 | message: "#^Method Spaceemotion\\\\PhpCodingStandard\\\\Tools\\\\Tool\\:\\:parseJson\\(\\) has parameter \\$raw with no typehint specified\\.$#" 30 | count: 1 31 | path: src/Tools/Tool.php 32 | 33 | - 34 | message: "#^Parameter \\#1 \\$commandName of method Symfony\\\\Component\\\\Console\\\\Application\\:\\:setDefaultCommand\\(\\) expects string, string\\|null given\\.$#" 35 | count: 1 36 | path: src/main.php 37 | 38 | -------------------------------------------------------------------------------- /src/main.php: -------------------------------------------------------------------------------- 1 | addTool(new ComposerNormalize()); 24 | $command->addTool(new PhpParallelLint()); 25 | $command->addTool(new Deptrac()); 26 | $command->addTool(new Rector()); 27 | $command->addTool(new EasyCodingStandard()); 28 | $command->addTool(new PhpCodeSniffer()); 29 | $command->addTool(new PhpMessDetector()); 30 | $command->addTool(new Phpstan()); 31 | $command->addTool(new Psalm()); 32 | $command->addTool(new Phan()); 33 | 34 | $application = new Application('phpcstd'); 35 | 36 | $application->add($command); 37 | $application->setDefaultCommand($command->getName()); 38 | $application->run(); 39 | -------------------------------------------------------------------------------- /src/Formatter/GithubActionFormatter.php: -------------------------------------------------------------------------------- 1 | files as $fileName => $file) { 17 | $fullPath = PHPCSTD_ROOT . $fileName; 18 | $violations = []; 19 | 20 | foreach ($file->violations as $violation) { 21 | $type = strtolower($violation->severity); 22 | $violations[$violation->line][$type][] = "{$violation->message} ({$violation->tool})"; 23 | } 24 | 25 | $style->writeln("::group::{$fileName}", Output::OUTPUT_RAW); 26 | 27 | foreach ($violations as $line => $byType) { 28 | foreach ($byType as $type => $violation) { 29 | // Replace NL with url-encoded %0A so they show up 30 | $violation = trim(strip_tags(implode("\n", $violation))); 31 | $violation = str_replace("\n", '%0A', $violation); 32 | 33 | $style->writeln( 34 | "::{$type} file={$fullPath},line={$line}::{$violation}", 35 | Output::OUTPUT_RAW 36 | ); 37 | } 38 | } 39 | 40 | $style->writeln('::endgroup::', Output::OUTPUT_RAW); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.phpcstd.dist.ini: -------------------------------------------------------------------------------- 1 | ; This file will always be used as the base configuration 2 | 3 | ; Any config can define a list of includes, like so: 4 | ; include[] = "path/to/file.ini" 5 | ; 6 | ; you can also skip the array declaration: 7 | ; include = "path/to/file.ini" 8 | 9 | ; Define a list of source folders to check against 10 | source[] = composer.json 11 | source[] = src 12 | source[] = tests 13 | 14 | ; Runs the command with --fix enabled before continuing as usual 15 | autofix = false 16 | 17 | ; Indicates whether the run should continue whenever an error has been found 18 | ; This might reduce the time running phpcstd against your codebase, 19 | ; but also requires multiple runs to find all errors. 20 | continue = false 21 | 22 | ; Configuration for Easy Coding Standard 23 | [ecs] 24 | enabled = false 25 | 26 | ; Configuration for PHP Mess Detector 27 | [phpmd] 28 | enabled = false 29 | 30 | ; Configuration for phpstan 31 | [phpstan] 32 | enabled = false 33 | ignoreSources = true 34 | 35 | ; Configuration for php-parallel-lint 36 | [parallel-lint] 37 | enabled = false 38 | ;processes = 24 39 | 40 | ; Configuration for deptrac 41 | [deptrac] 42 | enabled = false 43 | 44 | ; Configuration for the "composer normalize" plugin 45 | [composer-normalize] 46 | enabled = false 47 | updateLock = true 48 | 49 | ; The path to the composer binary 50 | binary = "composer" 51 | 52 | ; Configuration for vimeo/psalm 53 | [psalm] 54 | enabled = false 55 | ; config = psalm.xml 56 | 57 | ; Configuration for PHP_CodeSniffer 58 | [php_codesniffer] 59 | enabled = false 60 | ;processes = 24 61 | 62 | ; Configuration for phan/phan 63 | [phan] 64 | enabled = false 65 | 66 | ; Configuration for rector 67 | [rector] 68 | enabled = false 69 | -------------------------------------------------------------------------------- /src/Cli.php: -------------------------------------------------------------------------------- 1 | execute(self::vendorBinary($this->name), [ 23 | implode(',', $context->files), 24 | 'json', 25 | 'phpmd.xml', 26 | ], $output) === 0 27 | ) { 28 | return true; 29 | } 30 | 31 | $json = self::parseJson(implode('', $output)); 32 | 33 | if ($json === []) { 34 | return false; 35 | } 36 | 37 | $result = new Result(); 38 | 39 | foreach ($json['files'] as $entry) { 40 | $file = new File(); 41 | 42 | foreach ($entry['violations'] as $details) { 43 | $violation = new Violation(); 44 | $violation->line = (int) $details['beginLine']; 45 | $violation->message = $details['description']; 46 | $violation->source = "{$details['ruleSet']} > {$details['rule']} ({$details['externalInfoUrl']})"; 47 | $violation->tool = $this->name; 48 | 49 | $file->violations[] = $violation; 50 | } 51 | 52 | $result->files[$entry['file']] = $file; 53 | } 54 | 55 | $context->addResult($result); 56 | 57 | return false; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DiffViolation.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public static function make(Tool $tool, string $raw, callable $messageCallback): array 24 | { 25 | $violations = []; 26 | $matches = []; 27 | 28 | // 0 = all 29 | // 1 = from line number 30 | // 2 = from length 31 | // 3 = to line number 32 | // 4 = to length 33 | // 5 = diff excerpt 34 | preg_match_all( 35 | '/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@(.+?)(?=@@|\Z)/ms', 36 | $raw, 37 | $matches, 38 | PREG_SET_ORDER 39 | ); 40 | 41 | foreach ($matches as $idx => $match) { 42 | $message = $messageCallback($idx); 43 | 44 | $fromLineNumber = (int) $match[1]; 45 | 46 | $highlighted = preg_replace( 47 | ['/^-.*/m', '/^\+.*/m'], 48 | ['$0', '$0'], 49 | trim($match[5], "\n\r") 50 | ); 51 | 52 | $violation = new Violation(); 53 | $violation->message = $message; 54 | $violation->tool = $tool->getName(); 55 | $violation->source = "...\n" . $highlighted . "\n..."; 56 | 57 | // offset from the diff 58 | $violation->line = $fromLineNumber + 3; 59 | 60 | $violations[] = $violation; 61 | } 62 | 63 | return $violations; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Tools/Phan.php: -------------------------------------------------------------------------------- 1 | createTempReportFile(); 22 | 23 | if ( 24 | $this->execute(self::vendorBinary('phan'), array_merge( 25 | [ 26 | '--output-mode=json', 27 | '--output=' . $outputFile, 28 | '--no-color', 29 | ], 30 | extension_loaded('ast') ? [] : ['--allow-polyfill-parser'], 31 | $context->isFixing ? ['--automatic-fix'] : [] 32 | )) === 0 33 | ) { 34 | return true; 35 | } 36 | 37 | $json = self::parseJson(file_get_contents($outputFile)); 38 | 39 | if ($json === []) { 40 | return false; 41 | } 42 | 43 | foreach ($json as $entry) { 44 | $violation = new Violation(); 45 | $violation->message = $entry['description']; 46 | $violation->tool = $this->name; 47 | $violation->line = $entry['location']['lines']['begin']; 48 | $violation->source = "{$entry['check_name']} ({$entry['type_id']})"; 49 | 50 | $file = new File(); 51 | $file->violations[] = $violation; 52 | 53 | $result = new Result(); 54 | $result->files[$entry['location']['path']] = $file; 55 | 56 | $context->addResult($result); 57 | } 58 | 59 | return false; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | services(); 16 | $services->set(ArraySyntaxFixer::class) 17 | ->call('configure', [[ 18 | 'syntax' => 'short', 19 | ]]); 20 | 21 | $services->set(PhpCsFixer\Fixer\Phpdoc\PhpdocLineSpanFixer::class) 22 | ->call('configure', [[ 23 | 'const' => 'single', 24 | 'property' => 'single', 25 | 'method' => 'multi', 26 | ]]); 27 | 28 | $services->set(PhpCsFixer\Fixer\Import\OrderedImportsFixer::class) 29 | ->call('configure', [[ 30 | 'sort_algorithm' => 'alpha', 31 | 'imports_order' => ['class', 'function', 'const'], 32 | ]]); 33 | 34 | $services->set(PhpCsFixer\Fixer\Import\GlobalNamespaceImportFixer::class) 35 | ->call('configure', [[ 36 | 'import_classes' => true, 37 | 'import_constants' => true, 38 | 'import_functions' => true, 39 | ]]); 40 | 41 | $services->set(PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer::class) 42 | ->call('configure', [[ 43 | 'case' => 'snake_case', 44 | ]]); 45 | 46 | $parameters = $containerConfigurator->parameters(); 47 | $parameters->set(Option::LINE_ENDING, "\n"); 48 | $parameters->set(Option::PATHS, [ 49 | __DIR__ . '/bin', 50 | __DIR__ . '/src', 51 | __DIR__ . '/tests', 52 | ]); 53 | 54 | $parameters->set(Option::SETS, [ 55 | SetList::COMMON, 56 | SetList::CLEAN_CODE, 57 | // SetList::DEAD_CODE, 58 | SetList::PSR_12, 59 | // SetList::PHP_70, 60 | // SetList::PHP_71, 61 | ]); 62 | }; 63 | -------------------------------------------------------------------------------- /src/Tools/Rector.php: -------------------------------------------------------------------------------- 1 | execute($binary, array_merge([ 32 | 'process', 33 | '--output-format=json', 34 | '--no-progress-bar', 35 | $context->isFixing ? '' : '--dry-run', 36 | ], $context->files), $output) === 0 37 | ) { 38 | return true; 39 | } 40 | 41 | $json = self::parseJson(end($output)); 42 | 43 | $result = new Result(); 44 | 45 | foreach ($json['file_diffs'] ?? [] as $diff) { 46 | $file = new File(); 47 | $file->violations = DiffViolation::make($this, $diff['diff'], function (int $idx) use ($diff): string { 48 | return $idx > 0 49 | ? '(contd.)' 50 | : "Code issues:\n- " . implode( 51 | "\n- ", 52 | self::prettifyRectors($diff['applied_rectors']) 53 | ); 54 | }); 55 | 56 | $result->files[$diff['file']] = $file; 57 | } 58 | 59 | $context->addResult($result); 60 | 61 | return false; 62 | } 63 | 64 | /** 65 | * @param string[] $applied_rectors 66 | * @return string[] 67 | * 68 | * @psalm-return array 69 | */ 70 | private static function prettifyRectors(array $applied_rectors): array 71 | { 72 | return array_map(static function (string $rector): string { 73 | $className = basename(str_replace(['\\'], '/', $rector)); 74 | $withoutSuffix = preg_replace('/Rector$/', '', $className); 75 | 76 | $name = strtolower(preg_replace('/(?({$rector})"; 79 | }, $applied_rectors); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Tools/Deptrac.php: -------------------------------------------------------------------------------- 1 | createTempReportFile(); 22 | 23 | if ( 24 | $this->execute(self::vendorBinary('deptrac'), [ 25 | '--formatter=xml', 26 | '--no-progress', 27 | '--no-interaction', 28 | $this->useNewOutputFormat() 29 | ? "--output={$outputFile}" 30 | : "--xml-dump={$outputFile}", 31 | ]) === 0 32 | ) { 33 | return true; 34 | } 35 | 36 | $entries = simplexml_load_string(file_get_contents($outputFile)); 37 | 38 | if ($entries === false) { 39 | return false; 40 | } 41 | 42 | foreach ($entries->entry as $entry) { 43 | $layerA = (string) $entry->LayerA; 44 | $layerB = (string) $entry->LayerB; 45 | $classA = (string) $entry->ClassA; 46 | $classB = (string) $entry->ClassB; 47 | 48 | $occurrence = $entry->occurrence; 49 | 50 | $violation = new Violation(); 51 | $violation->message = "{$classA} must not depend on {$classB}"; 52 | $violation->source = "{$layerA} on {$layerB}"; 53 | $violation->tool = $this->getName(); 54 | $violation->line = (int) $occurrence['line']; 55 | 56 | $file = new File(); 57 | $file->violations[] = $violation; 58 | 59 | $result = new Result(); 60 | $result->files[(string) $occurrence['file']] = $file; 61 | 62 | $context->addResult($result); 63 | } 64 | 65 | return false; 66 | } 67 | 68 | protected function useNewOutputFormat(): bool 69 | { 70 | $output = []; 71 | $matches = []; 72 | 73 | $this->execute(self::vendorBinary('deptrac'), ['--version'], $output); 74 | 75 | preg_match('/(?[\d.]+)/', implode(' ', $output), $matches); 76 | 77 | if (isset($matches['version'])) { 78 | return ((float) $matches['version']) >= 0.19; 79 | } 80 | 81 | return false; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Tools/ComposerNormalize.php: -------------------------------------------------------------------------------- 1 | files, true)) { 31 | return false; 32 | } 33 | 34 | return parent::shouldRun($context); 35 | } 36 | 37 | public function run(Context $context): bool 38 | { 39 | $config = $context->config->getPart($this->name); 40 | 41 | $binary = $config['binary'] ?? 'composer'; 42 | $filename = PHPCSTD_ROOT . self::COMPOSER_FILE; 43 | 44 | $output = []; 45 | 46 | if ( 47 | $this->execute($binary, [ 48 | 'normalize', 49 | $filename, 50 | '--diff', 51 | (bool) $config['updateLock'] ? '' : '--no-update-lock', 52 | $context->isFixing ? '' : '--dry-run', 53 | ], $output) === 0 54 | ) { 55 | return true; 56 | } 57 | 58 | $file = new File(); 59 | $text = trim(preg_replace('/^Running ergebnis.*/', '', implode(PHP_EOL, $output))); 60 | 61 | $matches = []; 62 | 63 | if (preg_match('/^-{10,} begin diff -{10,}(.+)^-{10,} end diff -{10,}/ms', $text, $matches) === 1) { 64 | $file->violations = DiffViolation::make($this, $matches[1], static function (): string { 65 | return 'File is not normalized'; 66 | }); 67 | } 68 | 69 | if ($file->violations === []) { 70 | $violation = new Violation(); 71 | $violation->message = $text; 72 | $violation->tool = $this->getName(); 73 | 74 | $file->violations[] = $violation; 75 | } 76 | 77 | $result = new Result(); 78 | $result->files[$filename] = $file; 79 | 80 | $context->addResult($result); 81 | 82 | return false; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Tools/PhpCodeSniffer.php: -------------------------------------------------------------------------------- 1 | isFixing) { 20 | $this->sniff($context, 'phpcbf'); 21 | } 22 | 23 | $output = []; 24 | 25 | if ($this->sniff($context, 'phpcs', $output) === 0) { 26 | return true; 27 | } 28 | 29 | $json = self::parseJson(end($output)); 30 | $result = new Result(); 31 | 32 | foreach (($json['files'] ?? []) as $fileName => $details) { 33 | $messages = $details['messages']; 34 | 35 | if (! is_array($messages)) { 36 | continue; 37 | } 38 | 39 | $file = new File(); 40 | 41 | foreach ($messages as $message) { 42 | $violation = new Violation(); 43 | $violation->line = (int) $message['line']; 44 | $violation->message = $message['message']; 45 | $violation->severity = strtolower($message['type']); 46 | $violation->source = $message['source']; 47 | $violation->tool = $this->name; 48 | 49 | $file->violations[] = $violation; 50 | } 51 | 52 | $result->files[$fileName] = $file; 53 | } 54 | 55 | $context->result->add($result); 56 | 57 | $totals = $json['totals'] ?? []; 58 | 59 | // Return true as long as we can fix it 60 | if (! $context->isFixing) { 61 | return ($totals['errors'] ?? 0) === 0; 62 | } 63 | 64 | return ($totals['errors'] ?? 0) === ($totals['fixable'] ?? 0); 65 | } 66 | 67 | /** 68 | * @param string[] $output 69 | */ 70 | protected function sniff(Context $context, string $binary, array &$output = []): int 71 | { 72 | $config = $context->config->getPart($this->name); 73 | 74 | return $this->execute( 75 | self::vendorBinary($binary), 76 | array_merge( 77 | [ 78 | '--report=json', 79 | '--parallel=' . (int) ($config['processes'] ?? 24), 80 | ], 81 | $context->files 82 | ), 83 | $output 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spaceemotion/php-coding-standard", 3 | "description": "Combines multiple code quality tools into one binary with unified output.", 4 | "license": "ISC", 5 | "type": "library", 6 | "keywords": [ 7 | "phpcs", 8 | "linting", 9 | "phpstan", 10 | "phpmd", 11 | "analysis", 12 | "code quality" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "spaceemotion", 17 | "email": "hello@spaceemotion.net" 18 | } 19 | ], 20 | "homepage": "https://github.com/spaceemotion/php-coding-standard", 21 | "require": { 22 | "php": ">=7.1", 23 | "ext-SimpleXML": "*", 24 | "ext-json": "*", 25 | "composer/xdebug-handler": "^1.0 || ^2.0 || ^3.0", 26 | "symfony/console": "^3 || ^4 || ^5.2 || ^6 || ^7", 27 | "symfony/process": "^4.1 || ^5.2 || ^6 || ^7" 28 | }, 29 | "require-dev": { 30 | "brainmaestro/composer-git-hooks": "^2.8", 31 | "ergebnis/composer-normalize": "^2.4", 32 | "phan/phan": "^3.0 || ^4.0 || ^5.0", 33 | "php-parallel-lint/php-parallel-lint": "^1.2", 34 | "phpmd/phpmd": "^2.8", 35 | "phpstan/phpstan": "^1", 36 | "phpstan/phpstan-deprecation-rules": "^1.1", 37 | "phpstan/phpstan-strict-rules": "^1.5", 38 | "phpunit/phpunit": "^9.5 || ^10 || ^11", 39 | "psalm/plugin-phpunit": "^0.15.0", 40 | "qossmic/deptrac-shim": "^0.19.0 || ^1", 41 | "rector/rector": "^0.12", 42 | "squizlabs/php_codesniffer": "^3.5", 43 | "symplify/easy-coding-standard": "^10", 44 | "vimeo/psalm": "^4.3 || ^5" 45 | }, 46 | "suggest": { 47 | "ergebnis/composer-normalize": "Normalizes composer.json files", 48 | "phan/phan": "Static analysis (needs php-ast extension)", 49 | "php-parallel-lint/php-parallel-lint": "Quickly lints the whole codebase for PHP errors", 50 | "phpmd/phpmd": "Code mess detection", 51 | "phpstan/phpstan": "Static analysis", 52 | "psalm/phar": "Static analysis (.phar)", 53 | "squizlabs/php_codesniffer": "Code style linter + fixer", 54 | "symplify/easy-coding-standard": "Code style linter + fixer", 55 | "symplify/easy-coding-standard-prefixed": "Code style linter + fixer (.phar)", 56 | "vimeo/psalm": "Static analysis" 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true, 60 | "autoload": { 61 | "psr-4": { 62 | "Spaceemotion\\PhpCodingStandard\\": "src/" 63 | } 64 | }, 65 | "autoload-dev": { 66 | "psr-4": { 67 | "Tests\\": "tests/" 68 | } 69 | }, 70 | "bin": [ 71 | "bin/phpcstd" 72 | ], 73 | "config": { 74 | "allow-plugins": { 75 | "ergebnis/composer-normalize": true 76 | }, 77 | "sort-packages": true 78 | }, 79 | "extra": { 80 | "hooks": { 81 | "pre-commit": [ 82 | "bin/phpcstd --fix --hide-source --skip=phan --lint-staged --no-interaction" 83 | ] 84 | } 85 | }, 86 | "scripts": { 87 | "post-install-cmd": "cghooks add --ignore-lock", 88 | "post-update-cmd": "cghooks update" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Tools/Psalm.php: -------------------------------------------------------------------------------- 1 | name); 26 | 27 | $config = $this->getConfigOption($context, $binary); 28 | 29 | if ($context->isFixing) { 30 | $this->execute($binary, array_merge( 31 | ['--alter', '--issues=all', '--no-progress'], 32 | $config, 33 | $context->files 34 | )); 35 | } 36 | 37 | $tmpFileJson = $this->createTempReportFile(); 38 | $output = []; 39 | $exitCode = $this->execute($binary, array_merge( 40 | [ 41 | '--monochrome', 42 | "--report={$tmpFileJson}", 43 | '--no-progress', 44 | ], 45 | $config, 46 | $context->files 47 | ), $output); 48 | 49 | $contents = file_get_contents($tmpFileJson); 50 | 51 | if ($contents === false) { 52 | throw new RuntimeException('Unable to read report file'); 53 | } 54 | 55 | $json = self::parseJson($contents); 56 | 57 | if ($json === []) { 58 | if ($exitCode === 0) { 59 | return true; 60 | } 61 | 62 | echo implode("\n", $output) . ' '; 63 | return false; 64 | } 65 | 66 | foreach ($json as $entry) { 67 | $violation = new Violation(); 68 | $violation->message = $entry['message']; 69 | $violation->tool = $this->name; 70 | $violation->line = $entry['line_from']; 71 | $violation->source = "{$entry['type']} ({$entry['link']})"; 72 | $violation->severity = $entry['severity'] === Violation::SEVERITY_ERROR 73 | ? Violation::SEVERITY_ERROR 74 | : Violation::SEVERITY_WARNING; 75 | 76 | $file = new File(); 77 | $file->violations[] = $violation; 78 | 79 | $result = new Result(); 80 | $result->files[$entry['file_path']] = $file; 81 | 82 | $context->addResult($result); 83 | } 84 | 85 | return $exitCode === 0; 86 | } 87 | 88 | /** 89 | * @return string[] 90 | * 91 | * @psalm-return array{0?: string} 92 | */ 93 | protected function getConfigOption(Context $context, string $binary): array 94 | { 95 | // Detect correct config location and pass it on to psalm 96 | $configName = $context->config->getPart('psalm')['config'] ?? 'psalm.xml'; 97 | $configName = $configName[0] !== '/' 98 | ? dirname($binary, 3) . DIRECTORY_SEPARATOR . $configName 99 | : $configName; 100 | 101 | return file_exists($configName) ? ["--config={$configName}"] : []; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Tools/PhpParallelLint/ContextOutput.php: -------------------------------------------------------------------------------- 1 | context = $context; 40 | } 41 | 42 | public function setOutput(OutputInterface $output): void 43 | { 44 | $this->output = $output; 45 | } 46 | 47 | /** 48 | * @SuppressWarnings(PHPMD.ShortMethodName) 49 | */ 50 | public function ok(): void 51 | { 52 | $this->progress->advance(); 53 | } 54 | 55 | public function skip(): void 56 | { 57 | $this->ok(); 58 | } 59 | 60 | public function error(): void 61 | { 62 | $this->ok(); 63 | } 64 | 65 | public function fail(): void 66 | { 67 | $this->ok(); 68 | } 69 | 70 | /** 71 | * @psalm-suppress MissingParamType 72 | */ 73 | public function setTotalFileCount($count): void 74 | { 75 | $this->progress = new ProgressBar($this->output, $count); 76 | $this->progress->setFormat(' %message%: %current%/%max% [%bar%] %percent:3s%%'); 77 | $this->progress->setMessage(PhpParallelLint::NAME); 78 | $this->progress->start(); 79 | } 80 | 81 | /** 82 | * @psalm-suppress MissingParamType 83 | */ 84 | public function writeHeader($phpVersion, $parallelJobs, $hhvmVersion = null): void 85 | { 86 | } 87 | 88 | /** 89 | * @psalm-suppress MissingParamType 90 | */ 91 | public function writeResult( 92 | \JakubOnderka\PhpParallelLint\Result $result, 93 | ErrorFormatter $errorFormatter, 94 | $ignoreFails 95 | ): void { 96 | $this->progress->clear(); 97 | 98 | foreach ($result->getErrors() as $error) { 99 | if (! ($error instanceof SyntaxError)) { 100 | continue; 101 | } 102 | 103 | $violation = new Violation(); 104 | $violation->line = (int) $error->getLine(); 105 | $violation->tool = PhpParallelLint::NAME; 106 | $violation->message = preg_replace( 107 | '~ in (.*?) on line \d+$~', 108 | '', 109 | $error->getNormalizedMessage() 110 | ); 111 | 112 | $file = new File(); 113 | $file->violations[] = $violation; 114 | 115 | $result = new Result(); 116 | $result->files[$error->getFilePath()] = $file; 117 | 118 | $this->context->addResult($result); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Tools/Phpstan.php: -------------------------------------------------------------------------------- 1 | config->getPart($this->getName())[Config::IGNORE_SOURCES] ?? false); 23 | $files = $ignoreSources ? [] : $context->files; 24 | 25 | $output = []; 26 | 27 | if ( 28 | $this->execute(self::vendorBinary($this->getName()), array_merge( 29 | [ 30 | 'analyse', 31 | '--error-format=json', 32 | '--no-ansi', 33 | '--no-interaction', 34 | '--no-progress', 35 | ], 36 | $files 37 | ), $output) === 0 38 | ) { 39 | return true; 40 | } 41 | 42 | $json = self::parseJson($this->getJsonLine($output)); 43 | $result = new Result(); 44 | 45 | $globalFile = new File(); 46 | $result->files[File::GLOBAL] = $globalFile; 47 | 48 | if ($json === []) { 49 | $message = trim(implode("\n", $output)); 50 | $match = []; 51 | 52 | if (preg_match('/(.*) in (.*?) on line (\d+)$/i', $message, $match) !== 1) { 53 | $violation = new Violation(); 54 | $violation->message = $message; 55 | $violation->tool = $this->getName(); 56 | 57 | $globalFile->violations[] = $violation; 58 | $context->addResult($result); 59 | 60 | return false; 61 | } 62 | 63 | $violation = new Violation(); 64 | $violation->line = (int) $match[3]; 65 | $violation->message = $match[1]; 66 | $violation->tool = $this->getName(); 67 | 68 | $file = new File(); 69 | $file->violations[] = $violation; 70 | $result->files[$match[2]] = $file; 71 | 72 | $context->addResult($result); 73 | 74 | return false; 75 | } 76 | 77 | foreach ($json['files'] as $filename => $details) { 78 | $file = new File(); 79 | 80 | foreach ($details['messages'] as $message) { 81 | $violation = new Violation(); 82 | $violation->line = (int) ($message['line'] ?? 0); 83 | $violation->message = $message['message']; 84 | $violation->tool = $this->getName(); 85 | 86 | $file->violations[] = $violation; 87 | } 88 | 89 | $result->files[$filename] = $file; 90 | } 91 | 92 | foreach ($json['errors'] as $error) { 93 | $violation = new Violation(); 94 | $violation->message = $error; 95 | $violation->tool = $this->getName(); 96 | 97 | $globalFile->violations[] = $violation; 98 | } 99 | 100 | $context->addResult($result); 101 | 102 | return false; 103 | } 104 | 105 | private function getJsonLine(array $output): string 106 | { 107 | foreach ($output as $line) { 108 | if (preg_match('/^{"totals/', $line) === 1) { 109 | return $line; 110 | } 111 | } 112 | 113 | return ''; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Tools/EasyCodingStandard.php: -------------------------------------------------------------------------------- 1 | execute(self::vendorBinary($this->name), array_merge( 32 | [ 33 | 'check', 34 | '--output-format=json', 35 | '--no-progress-bar', 36 | ], 37 | $context->isFixing ? ['--fix'] : [], 38 | $context->files 39 | ), $output); 40 | 41 | $outputText = implode('', $output); 42 | preg_match('/\{\s*"(totals|meta)".+/ms', $outputText, $matches); 43 | 44 | $json = self::parseJson($matches[0] ?? ''); 45 | 46 | if ($json === []) { 47 | return false; 48 | } 49 | 50 | $result = $this->parseResult($json['files'] ?? [], $context); 51 | 52 | $context->addResult($result); 53 | 54 | return $result->files === []; 55 | } 56 | 57 | /** 58 | * @param mixed[] $files 59 | */ 60 | protected function parseResult(array $files, Context $context): Result 61 | { 62 | $result = new Result(); 63 | 64 | foreach ($files as $path => $details) { 65 | $file = new File(); 66 | 67 | foreach (($details['errors'] ?? []) as $error) { 68 | $violation = new Violation(); 69 | $violation->line = $error['line']; 70 | $violation->message = $error['message']; 71 | $violation->source = $error['source_class']; 72 | $violation->tool = $this->name; 73 | 74 | $file->violations[] = $violation; 75 | } 76 | 77 | if (! $context->isFixing) { 78 | foreach (($details['diffs'] ?? []) as $diff) { 79 | $violations = DiffViolation::make( 80 | $this, 81 | $diff['diff'], 82 | static function (int $idx) use ($diff): string { 83 | return $idx > 0 84 | ? '(contd.)' 85 | : "Styling issues:\n- " . implode( 86 | "\n- ", 87 | self::prettifyCheckers($diff['applied_checkers']) 88 | ); 89 | } 90 | ); 91 | 92 | $file->violations = array_merge($file->violations, $violations); 93 | } 94 | } 95 | 96 | if ($file->violations !== []) { 97 | $result->files[$path] = $file; 98 | } 99 | } 100 | 101 | return $result; 102 | } 103 | 104 | /** 105 | * @param string[] $applied_checkers 106 | * @return string[] 107 | * 108 | * @psalm-return array 109 | */ 110 | private static function prettifyCheckers(array $applied_checkers): array 111 | { 112 | return array_map(static function (string $checker): string { 113 | $className = basename(str_replace(['\\', '.'], '/', $checker)); 114 | $withoutSuffix = preg_replace('/Fixer$/', '', $className) ?? $className; 115 | 116 | $name = strtolower( 117 | preg_replace('/(?({$checker})"; 121 | }, $applied_checkers); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | output = $output; 24 | 25 | foreach ($this->readConfigs() as $config) { 26 | $this->config = self::mergeConfig($this->config, $config); 27 | } 28 | } 29 | 30 | /** 31 | * Returns a list of file paths to lint/fix. 32 | * 33 | * @return string[] 34 | */ 35 | public function getSources(): array 36 | { 37 | return $this->config['source'] ?? []; 38 | } 39 | 40 | public function shouldContinue(): bool 41 | { 42 | return (bool) ($this->config['continue'] ?? false); 43 | } 44 | 45 | public function shouldAutoFix(): bool 46 | { 47 | return (bool) ($this->config['autofix'] ?? false); 48 | } 49 | 50 | public function isEnabled(string $toolName): bool 51 | { 52 | return (bool) ($this->config[$toolName]['enabled'] ?? false); 53 | } 54 | 55 | /** 56 | * @return mixed[] 57 | */ 58 | public function getPart(string $toolName): array 59 | { 60 | $contents = $this->config[$toolName] ?? []; 61 | 62 | if (! is_array($contents)) { 63 | return []; 64 | } 65 | 66 | return $contents; 67 | } 68 | 69 | /** 70 | * @return mixed[] 71 | */ 72 | protected function readConfig(string $path): array 73 | { 74 | if (! is_file($path)) { 75 | $path = preg_replace('/\.ini$/i', '.dist.ini', $path); 76 | 77 | if ($path === null || ! is_file($path)) { 78 | return []; 79 | } 80 | } 81 | 82 | $this->output->writeln("Including config: {$path}", Output::VERBOSITY_VERBOSE); 83 | 84 | $config = parse_ini_file($path, true); 85 | 86 | if ($config === false) { 87 | throw new RuntimeException( 88 | 'Unable to parse config. Please make sure that it\'s a valid ini formatted file.' 89 | ); 90 | } 91 | 92 | return $config; 93 | } 94 | 95 | /** 96 | * @param mixed[] $base 97 | * @param mixed[] $config 98 | * @return mixed[] 99 | */ 100 | protected static function mergeConfig(array $base, array $config): array 101 | { 102 | foreach ($config as $key => $value) { 103 | // Check if value is an associative array 104 | if (is_array($value) && array_keys($value) !== range(0, count($value) - 1)) { 105 | $value = self::mergeConfig($base[$key] ?? [], $value); 106 | } 107 | 108 | $base[$key] = $value; 109 | } 110 | 111 | return $base; 112 | } 113 | 114 | /** 115 | * @return array[] 116 | * 117 | * @psalm-return array 118 | */ 119 | protected function readConfigs(): array 120 | { 121 | $workingDirectory = getcwd(); 122 | 123 | if ($workingDirectory === false) { 124 | throw new RuntimeException('Unable to get working directory.'); 125 | } 126 | 127 | $configs = []; 128 | 129 | $paths = array_unique(array_filter([ 130 | $workingDirectory . '/.phpcstd.ini', 131 | dirname(__DIR__) . '/.phpcstd.ini', 132 | ])); 133 | 134 | while (($path = array_shift($paths)) !== null) { 135 | $config = $this->readConfig($path); 136 | 137 | foreach (array_reverse((array) ($config['include'] ?? [])) as $include) { 138 | $includePath = realpath(dirname($path) . '/' . $include); 139 | 140 | // Either the file does not exist 141 | if ($includePath === false) { 142 | $this->output->writeln( 143 | "Could not find config {$include}, skipping", 144 | Output::VERBOSITY_NORMAL 145 | ); 146 | continue; 147 | } 148 | 149 | // or we've already loaded its contents 150 | if (array_key_exists($includePath, $configs)) { 151 | $this->output->writeln( 152 | "Cyclic dependency found at {$path} for {$includePath}", 153 | Output::VERBOSITY_NORMAL 154 | ); 155 | continue; 156 | } 157 | 158 | array_unshift($paths, $includePath); 159 | } 160 | 161 | unset($config['include']); 162 | 163 | $configs[$path] = $config; 164 | } 165 | 166 | return array_reverse($configs); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Formatter/ConsoleFormatter.php: -------------------------------------------------------------------------------- 1 | 'fg=red', 27 | Violation::SEVERITY_WARNING => 'fg=yellow', 28 | ]; 29 | 30 | /** @var bool */ 31 | protected $printSource = false; 32 | 33 | public function __construct(bool $hideSource) 34 | { 35 | $this->printSource = ! $hideSource; 36 | } 37 | 38 | public function format(Result $result, SymfonyStyle $style): void 39 | { 40 | $counts = [ 41 | Violation::SEVERITY_WARNING => 0, 42 | Violation::SEVERITY_ERROR => 0, 43 | ]; 44 | 45 | $style->writeln(''); 46 | 47 | foreach ($result->files as $path => $file) { 48 | $style->writeln("{$path}"); 49 | 50 | foreach (self::sortByLineNumber($file->violations) as $violation) { 51 | $counts[$violation->severity]++; 52 | 53 | $severity = $this->colorize( 54 | self::COLOR_BY_SEVERITY[$violation->severity], 55 | strtoupper($violation->severity) 56 | ); 57 | 58 | $tool = $this->colorize('fg=blue', $violation->tool); 59 | 60 | $message = $this->highlightClasses($violation->message); 61 | 62 | if ($this->printSource && $violation->source !== '') { 63 | $message .= "\n{$violation->source}"; 64 | } 65 | 66 | $this->writeRow($style, [ 67 | (string) $violation->line, 68 | $severity, 69 | $tool, 70 | $message, 71 | ]); 72 | } 73 | 74 | $style->writeln('', Output::OUTPUT_RAW); 75 | } 76 | 77 | $results = implode(', ', array_map( 78 | static function (int $count, string $key): string { 79 | return "{$count} {$key}(s)"; 80 | }, 81 | $counts, 82 | array_keys($counts) 83 | )); 84 | 85 | if ($counts[Violation::SEVERITY_ERROR] > 0) { 86 | $style->error($results); 87 | return; 88 | } 89 | 90 | if ($counts[Violation::SEVERITY_WARNING] > 0) { 91 | $style->warning($results); 92 | return; 93 | } 94 | 95 | $style->success($results); 96 | } 97 | 98 | protected function colorize(string $color, string $text): string 99 | { 100 | return "<{$color}>{$text}"; 101 | } 102 | 103 | /** 104 | * @param Violation[] $violations 105 | * 106 | * @return Violation[] 107 | * 108 | * @psalm-return list 109 | */ 110 | protected static function sortByLineNumber(array $violations): array 111 | { 112 | uasort( 113 | $violations, 114 | static function (Violation $first, Violation $second): int { 115 | return $first->line <=> $second->line; 116 | } 117 | ); 118 | 119 | return array_values($violations); 120 | } 121 | 122 | /** 123 | * @param string[] $array 124 | */ 125 | protected function writeRow(OutputInterface $output, array $array): void 126 | { 127 | $widths = [5, 8, 15, 0]; 128 | $nlPrefix = str_repeat(' ', array_sum($widths) + count($widths) - 1); 129 | 130 | foreach ($array as $column => $cell) { 131 | foreach (explode("\n", $cell) as $idx => $line) { 132 | if ($widths[$column] > 0) { 133 | $rawLength = $widths[$column] + strlen($line) - strlen(strip_tags($line)); 134 | $line = str_pad($line, $rawLength, ' ', $column > 1 ? STR_PAD_RIGHT : STR_PAD_LEFT); 135 | } 136 | 137 | $output->write(($idx > 0 ? "\n{$nlPrefix}" : '') . $line . ' '); 138 | } 139 | } 140 | 141 | $output->write(PHP_EOL); 142 | } 143 | 144 | protected function highlightClasses(string $message): string 145 | { 146 | // Only highlight text when no tags exist yet 147 | if (strpos($message, '$0', $message) ?? $message; 153 | 154 | // Find classes/statics/const 155 | return (string) preg_replace( 156 | '/\\\\?[A-Za-z]+\\\\[A-Za-z\\\]+(::[a-zA-Z]+(\(\))?)?/S', 157 | '$0', 158 | $message 159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spaceemotion/php-coding-standard.svg?style=flat-square)](https://packagist.org/packages/spaceemotion/php-coding-standard) 2 | [![Total Downloads](https://img.shields.io/packagist/dt/spaceemotion/php-coding-standard.svg?style=flat-square)](https://packagist.org/packages/spaceemotion/php-coding-standard) 3 | 4 | # php-coding-standard (phpcstd) 5 | 6 | diagram of the project workflow 7 | 8 | `phpcstd` combines various code quality tools (e.g. linting and static analysis) 9 | into one, easy to use package which can be shared across teams and code bases. 10 | 11 | There are two parts to this: 12 | 1. `phpcstd` executes all the enabled tools and returns a single per-file error output 13 | 2. In your projects, you depend on a single repository (e.g. `acme/coding-standard`) 14 | which depends on `phpcstd` and includes the various base configurations 15 | (e.g. phpmd.xml, ecs.yaml, ...). Your own projects then depend on your own coding standard. 16 | 17 | `phpcstd` itself does not come with any tools preinstalled. 18 | You can take a look at [my own coding standards](https://github.com/spaceemotion/my-php-coding-standard) as an example. 19 | 20 | #### Tools supported 21 | Tool | Lint | Fix | Source list | Description 22 | -----|------|-----|-------------|----------- 23 | ⭐ [EasyCodingStandard](https://github.com/symplify/easy-coding-standard) | ✅ | ✅ | ✅ | Combination of PHP_CodeSniffer and PHP-CS-Fixer 24 | [PHP Mess Detector](https://github.com/phpmd/phpmd) | ✅ | ❌ | ✅ | Code complexity checker 25 | [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) | ✅ | ✅ | ✅ | Style linter + fixer 26 | ⭐ [composer-normalize](https://github.com/ergebnis/composer-normalize) | ✅ | ✅ | ✅ | Validates and rearranges composer.json files 27 | [phan](https://github.com/phan/phan) | ✅ | ✅ | ❌ | Static analyzer, requires the "php-ast" extension 28 | ⭐ [php-parallel-lint](https://github.com/php-parallel-lint/php-parallel-lint) | ✅ | ❌ | ✅ | Checks for PHP (syntax) errors (using `php -l`) 29 | ⭐ [phpstan](https://github.com/phpstan/phpstan) | ✅ | ❌ | ⏹ | Static analyzer, source list is optional, but not recommended 30 | [psalm](https://github.com/vimeo/psalm) | ✅ | ✅ | ✅ | Static analyzer 31 | ⭐ [rector](https://github.com/rectorphp/rector) | ✅ | ✅ | ✅ | Code up-/downgrading and refactoring tool 32 | [deptrac](https://github.com/qossmic/deptrac) | ✅ | ❌ | ❌ | Static analyzer that enforces rules for dependencies between software layers 33 | 34 | _⭐ = recommended_ 35 | 36 | ## Getting started 37 | ``` 38 | composer require-dev spaceemotion/php-coding-standard 39 | ``` 40 | 41 | This will install the `phpcstd` binary to your vendor folder. 42 | 43 | ### Configuration via .phpcstd(.dist).ini 44 | To minimize dependencies, `phpcstd` uses .ini-files for its configuration. 45 | If no `.phpcstd.ini` file can be found in your project folder, 46 | a `.phpcstd.dist.ini` file will be used as fallback (if possible). 47 | 48 | ### Command options 49 | ``` 50 | Usage: 51 | run [options] [--] [...] 52 | 53 | Arguments: 54 | files List of files to parse instead of the configured sources 55 | 56 | Options: 57 | -s, --skip=SKIP Disables the list of tools during the run (comma-separated list) (multiple values allowed) 58 | -o, --only=ONLY Only executes the list of tools during the run (comma-separated list) (multiple values allowed) 59 | --continue Run the next check even if the previous one failed 60 | --fix Try to fix any linting errors 61 | --hide-source Hides the "source" lines from console output 62 | --lint-staged Uses "git diff" to determine staged files to lint 63 | --ci Changes the output format to GithubActions for better CI integration 64 | --no-fail Only returns with exit code 0, regardless of any errors/warnings 65 | -h, --help Display help for the given command. When no command is given display help for the run command 66 | -q, --quiet Do not output any message 67 | -V, --version Display this application version 68 | --ansi Force ANSI output 69 | --no-ansi Disable ANSI output 70 | -n, --no-interaction Do not ask any interactive question 71 | -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug 72 | ``` 73 | 74 | Instead of defining the files/folders directly (in either the config or as arguments), you can also pipe a list into it: 75 | ``` 76 | $ ls -A1 | vendor/bin/phpcstd 77 | ``` 78 | 79 | ## Git Hooks 80 | To not have to wait for CI pipelines to finish, you can use git hooks to run over the changed files before committing. 81 | 82 | ```sh 83 | vendor/bin/phpcstd --lint-staged 84 | ``` 85 | 86 | ## CI-Support 87 | ### Github Actions 88 | The `--ci` flag returns a format that can be used by GithubActions to annotate commits and PRs 89 | (see [their documentation on how this works](https://github.com/actions/toolkit/blob/master/docs/commands.md#problem-matchers)). 90 | 91 | ![example file change with an error](./img/github-annotation.png) 92 | 93 | ## Development 94 | ### Using Docker 95 | 1. Spin up the container using `GITHUB_PERSONAL_ACCESS_TOKEN= docker-compose up -d --build` 96 | 2. Run all commands using `docker-compose exec php ` 97 | 98 | ### Using XDebug 99 | This project uses [composer/xdebug-handler](https://github.com/composer/xdebug-handler) to improve performance 100 | by disabling xdebug upon startup. To enable XDebug during development you need to set the following env variable: 101 | `PHPCSTD_ALLOW_XDEBUG=1` (as written in their README). 102 | -------------------------------------------------------------------------------- /src/Tools/Tool.php: -------------------------------------------------------------------------------- 1 | input = $input; 41 | } 42 | 43 | public function setOutput(OutputInterface $output): void 44 | { 45 | $this->output = $output; 46 | } 47 | 48 | /** 49 | * Runs this tool with the given context. 50 | */ 51 | abstract public function run(Context $context): bool; 52 | 53 | /** 54 | * Indicates if this should should run for the given context. 55 | */ 56 | public function shouldRun(Context $context): bool 57 | { 58 | return true; 59 | } 60 | 61 | public function getName(): string 62 | { 63 | return $this->name; 64 | } 65 | 66 | /** 67 | * Runs the given command list in sequence. 68 | * On windows, the .bat file will be executed instead. 69 | * 70 | * @param string $binary The raw binary name 71 | * @param string[] $arguments 72 | * @param string[] $output 73 | * 74 | * @psalm-suppress ReferenceConstraintViolation 75 | * 76 | * @return int The exit code of the command 77 | */ 78 | protected function execute( 79 | string $binary, 80 | array $arguments, 81 | array &$output = [] 82 | ): int { 83 | $arguments = array_filter($arguments, static function ($argument): bool { 84 | return $argument !== ''; 85 | }); 86 | 87 | $command = array_merge([$binary], $arguments); 88 | 89 | if ($this->output->isVeryVerbose()) { 90 | $this->output->writeln('Executing: ' . implode(' ', $command), Output::OUTPUT_RAW); 91 | } 92 | 93 | $process = new Process($command); 94 | $process->setTimeout(null); 95 | 96 | if ($this->output->isDebug()) { 97 | return $process->run(function (string $type, string $buffer) use (&$output): void { 98 | $this->output->write($buffer); 99 | $output[] = $buffer; 100 | }); 101 | } 102 | 103 | $isInteractive = $this->input !== null && $this->input->isInteractive(); 104 | 105 | $progress = $this->createProgressBar($isInteractive); 106 | $progress->start(); 107 | 108 | $process->start(static function (string $type, string $buffer) use (&$output): void { 109 | $output[] = $buffer; 110 | }); 111 | 112 | while ($process->isRunning()) { 113 | if ($isInteractive) { 114 | $progress->setProgressCharacter(self::CHARS[$progress->getProgress() % 8]); 115 | } 116 | 117 | $progress->advance(); 118 | 119 | // Create two rotations per second 120 | usleep((int) ((1 / 8 / 2) * 1000000)); 121 | } 122 | 123 | $progress->clear(); 124 | 125 | if (! $isInteractive) { 126 | $this->output->writeln(''); 127 | } 128 | 129 | return (int) $process->getExitCode(); 130 | } 131 | 132 | protected static function vendorBinary(string $binary): string 133 | { 134 | $binary = PHPCSTD_BINARY_PATH . $binary; 135 | 136 | if (! is_file($binary)) { 137 | $binary = "{$binary}.phar"; 138 | } 139 | 140 | if (Cli::isOnWindows()) { 141 | $binary = "{$binary}.bat"; 142 | } 143 | 144 | return $binary; 145 | } 146 | 147 | protected function createTempReportFile(): string 148 | { 149 | $tmpFile = tempnam(sys_get_temp_dir(), $this->name); 150 | 151 | if ($tmpFile === false) { 152 | throw new RuntimeException('Unable to create temporary report file'); 153 | } 154 | 155 | $tmpFileJson = "{$tmpFile}.json"; 156 | 157 | if (! rename($tmpFile, $tmpFileJson)) { 158 | throw new RuntimeException('Unable to rename temporary report file'); 159 | } 160 | 161 | return $tmpFileJson; 162 | } 163 | 164 | /** 165 | * @return mixed[] 166 | */ 167 | protected static function parseJson($raw): array 168 | { 169 | if (! is_string($raw)) { 170 | return []; 171 | } 172 | 173 | // Clean up malformed JSON output 174 | $matches = []; 175 | preg_match('/((?:{.*})|(?:\[.*\]))\s*$/msS', $raw, $matches); 176 | 177 | $json = json_decode($matches[1] ?? $raw, true, 512); 178 | 179 | if (json_last_error() === JSON_ERROR_NONE) { 180 | return $json; 181 | } 182 | 183 | return []; 184 | } 185 | 186 | protected function createProgressBar(bool $isInteractive): ProgressBar 187 | { 188 | $progress = new ProgressBar($this->output); 189 | $progress->setMessage($this->getName()); 190 | $progress->setBarWidth(1); 191 | $progress->setFormat('%bar% %message% (elapsed: %elapsed:6s%)'); 192 | 193 | if ($isInteractive) { 194 | $progress->setRedrawFrequency(1); 195 | $progress->setBarCharacter(self::CHARS[0]); 196 | 197 | return $progress; 198 | } 199 | 200 | $progress->minSecondsBetweenRedraws(5); 201 | $progress->maxSecondsBetweenRedraws(5); 202 | $progress->setOverwrite(false); 203 | 204 | return $progress; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Commands/RunCommand.php: -------------------------------------------------------------------------------- 1 | tools[] = $tool; 46 | } 47 | 48 | protected function configure(): void 49 | { 50 | $this->addOption( 51 | Cli::PARAMETER_SKIP, 52 | 's', 53 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 54 | 'Disables the list of tools during the run (comma-separated list)' 55 | ); 56 | 57 | $this->addOption( 58 | Cli::PARAMETER_ONLY, 59 | 'o', 60 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 61 | 'Only executes the list of tools during the run (comma-separated list)' 62 | ); 63 | 64 | $this->addOption( 65 | Cli::FLAG_CONTINUE, 66 | null, 67 | InputOption::VALUE_NONE, 68 | 'Run the next check even if the previous one failed' 69 | ); 70 | 71 | $this->addOption( 72 | Cli::FLAG_INTERACTIVE, 73 | null, 74 | InputOption::VALUE_NONE, 75 | 'Force interactive mode' 76 | ); 77 | 78 | $this->addOption( 79 | Cli::FLAG_FIX, 80 | null, 81 | InputOption::VALUE_NONE, 82 | 'Try to fix any linting errors' 83 | ); 84 | 85 | $this->addOption( 86 | Cli::FLAG_HIDE_SOURCE, 87 | null, 88 | InputOption::VALUE_NONE, 89 | 'Hides the "source" lines from console output' 90 | ); 91 | 92 | $this->addOption( 93 | Cli::FLAG_LINT_STAGED, 94 | null, 95 | InputOption::VALUE_NONE, 96 | 'Uses "git diff" to determine staged files to lint' 97 | ); 98 | 99 | $this->addOption( 100 | Cli::FLAG_CI, 101 | null, 102 | InputOption::VALUE_NONE, 103 | 'Changes the output format to GithubActions for better CI integration' 104 | ); 105 | 106 | $this->addOption( 107 | Cli::FLAG_NO_FAIL, 108 | null, 109 | InputOption::VALUE_NONE, 110 | 'Only returns with exit code 0, regardless of any errors/warnings' 111 | ); 112 | 113 | $this->addArgument( 114 | 'files', 115 | InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 116 | 'List of files to parse instead of the configured sources' 117 | ); 118 | } 119 | 120 | protected function execute(InputInterface $input, OutputInterface $output): int 121 | { 122 | $this->config = new Config($output); 123 | 124 | $files = $this->getFiles($input); 125 | 126 | if ($files === []) { 127 | $output->writeln('No files specified'); 128 | return self::SUCCESS; 129 | } 130 | 131 | $context = new Context($this->config); 132 | $context->isFixing = (bool) $input->getOption(Cli::FLAG_FIX) || $this->config->shouldAutoFix(); 133 | $context->runningInCi = (bool) $input->getOption(Cli::FLAG_CI); 134 | $context->files = $files; 135 | 136 | if ($context->runningInCi) { 137 | $input->setInteractive(false); 138 | } 139 | 140 | if ((bool) $input->getOption(Cli::FLAG_INTERACTIVE)) { 141 | $input->setInteractive(true); 142 | } 143 | 144 | $success = $this->executeContext($input, $output, $context); 145 | 146 | $hideSource = (bool) $input->getOption(Cli::FLAG_HIDE_SOURCE); 147 | 148 | $formatter = $context->runningInCi 149 | ? new GithubActionFormatter($hideSource) 150 | : new ConsoleFormatter($hideSource); 151 | 152 | $formatter->format($context->result, new SymfonyStyle($input, $output)); 153 | 154 | return (bool) $input->getOption(Cli::FLAG_NO_FAIL) ? self::SUCCESS : (int) ! $success; 155 | } 156 | 157 | private function executeContext(InputInterface $input, OutputInterface $output, Context $context): bool 158 | { 159 | $grayStyle = $this->getOutputStyle('#777'); 160 | 161 | $output->getFormatter()->setStyle('gray', $grayStyle); 162 | 163 | $skipped = (array) $input->getOption(Cli::PARAMETER_SKIP); 164 | $only = (array) $input->getOption(Cli::PARAMETER_ONLY); 165 | 166 | $continue = (bool) $input->getOption(Cli::FLAG_CONTINUE) || $this->config->shouldContinue(); 167 | $success = true; 168 | 169 | foreach ($this->tools as $tool) { 170 | $name = $tool->getName(); 171 | 172 | if (! $context->config->isEnabled($name)) { 173 | // Don't show a message for tools we don't need/have 174 | continue; 175 | } 176 | 177 | if ( 178 | // Check against --skip 179 | in_array($name, $skipped, true) 180 | // Check against --only 181 | || ($only !== [] && ! in_array($name, $only, true)) 182 | // Additional checks 183 | || ! $tool->shouldRun($context) 184 | ) { 185 | $output->writeln("- {$name}: SKIP"); 186 | continue; 187 | } 188 | 189 | $tool->setInput($input); 190 | $tool->setOutput($output); 191 | 192 | $start = time(); 193 | $result = $tool->run($context); 194 | $timeTaken = '' . Helper::formatTime(time() - $start) . ''; 195 | 196 | if ($result) { 197 | $output->writeln("✔ {$name}: OK {$timeTaken}"); 198 | continue; 199 | } 200 | 201 | $output->writeln("✘ {$name}: FAIL {$timeTaken}"); 202 | 203 | $success = false; 204 | 205 | if (! $continue) { 206 | break; 207 | } 208 | } 209 | 210 | return $success; 211 | } 212 | 213 | /** 214 | * Calls 'git diff' to determine changed files. 215 | * 216 | * @return string[] 217 | */ 218 | private function lintStaged(): array 219 | { 220 | $output = []; 221 | 222 | exec('git diff --name-status --cached', $output); 223 | 224 | return array_filter(array_map(static function (string $line): string { 225 | // Only count added or modified files 226 | return $line[0] === 'A' || $line[0] === 'M' ? ltrim(substr($line, 1)) : ''; 227 | }, $output)); 228 | } 229 | 230 | /** 231 | * @return string[] 232 | */ 233 | private function getFiles(InputInterface $input): array 234 | { 235 | // 1. --lint-staged takes precedence 236 | if ((bool) ($input->getOption(Cli::FLAG_LINT_STAGED))) { 237 | return $this->lintStaged(); 238 | } 239 | 240 | // 2. Then read from STDIN (e.g. ls -A1 | ....) 241 | $stdin = Cli::parseFilesFromInput(); 242 | 243 | if ($stdin !== []) { 244 | return $stdin; 245 | } 246 | 247 | // 3. Read from argument 248 | $files = array_map('strval', (array) $input->getArgument('files')); 249 | 250 | if ($files !== []) { 251 | return $files; 252 | } 253 | 254 | // 4. Read from config 255 | return $this->config->getSources(); 256 | } 257 | 258 | private function getOutputStyle(string $foreground): OutputFormatterStyle 259 | { 260 | try { 261 | return new OutputFormatterStyle($foreground); 262 | } catch (InvalidArgumentException $ex) { 263 | return new OutputFormatterStyle('default'); 264 | } 265 | } 266 | } 267 | --------------------------------------------------------------------------------