├── .gitignore ├── PhpcsChanged ├── ShellException.php ├── NoChangesException.php ├── InvalidOptionException.php ├── CacheInterface.php ├── CacheObject.php ├── Modes.php ├── Reporter.php ├── PhpcsMessages.php ├── DiffLineType.php ├── functions.php ├── DiffLine.php ├── CacheEntry.php ├── ShellOperator.php ├── LintMessage.php ├── PhpcsMessagesHelpers.php ├── CheckstyleReporter.php ├── JsonReporter.php ├── FileCache.php ├── FullReporter.php ├── XmlReporter.php ├── JunitReporter.php ├── DiffLineMap.php ├── LintMessages.php ├── CacheManager.php ├── CliOptions.php └── UnixShell.php ├── phpcs.xml ├── phpunit.xml.dist ├── tests ├── helpers │ ├── TestXmlReporter.php │ ├── Functions.php │ ├── helpers.php │ ├── PhpcsFixture.php │ ├── TestCache.php │ ├── GitFixture.php │ ├── SvnFixture.php │ └── TestShell.php ├── fixtures │ ├── review-stuck-orders.diff │ ├── old-phpcs-output.json │ └── new-phpcs-output.json ├── CacheManagerTest.php ├── CliTest.php ├── PhpcsMessagesTest.php ├── DiffLineMapTest.php ├── CheckstyleReporterTest.php ├── JsonReporterTest.php ├── FullReporterTest.php ├── JunitReporterTest.php ├── XmlReporterTest.php └── PhpcsChangedTest.php ├── psalm.xml ├── LICENSE ├── composer.json ├── index.php ├── .github └── workflows │ ├── csqa.yml │ └── test.yml ├── bin └── phpcs-changed └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /PhpcsChanged/ShellException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Run phpcs on files, but only report warnings/errors from lines which were changed. 4 | 5 | 6 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /PhpcsChanged/CacheInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/helpers/PhpcsFixture.php: -------------------------------------------------------------------------------- 1 | 'ERROR', 12 | 'severity' => 5, 13 | 'fixable' => false, 14 | 'column' => 5, 15 | 'source' => 'Variables.Defined.RequiredDefined.Unused', 16 | 'line' => $lineNumber, 17 | 'message' => $message, 18 | ]; 19 | }, $lineNumbers); 20 | return PhpcsMessages::fromArrays($arrays, $filename); 21 | } 22 | 23 | public function getEmptyResults(): PhpcsMessages { 24 | return PhpcsMessages::fromPhpcsJson('{"totals":{"errors":0,"warnings":0,"fixable":0},"files":{}}'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /PhpcsChanged/DiffLineType.php: -------------------------------------------------------------------------------- 1 | type = $type; 11 | } 12 | 13 | public static function makeAdd(): self { 14 | return new self('add'); 15 | } 16 | 17 | public static function makeRemove(): self { 18 | return new self('remove'); 19 | } 20 | 21 | public static function makeContext(): self { 22 | return new self('context'); 23 | } 24 | 25 | public function isAdd(): bool { 26 | return $this->type === 'add'; 27 | } 28 | 29 | public function isRemove(): bool { 30 | return $this->type === 'remove'; 31 | } 32 | 33 | public function isContext(): bool { 34 | return $this->type === 'context'; 35 | } 36 | 37 | public function __toString(): string { 38 | return $this->type; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Payton Swick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PhpcsChanged/functions.php: -------------------------------------------------------------------------------- 1 | type = $type; 31 | $this->line = $line; 32 | if (! $type->isAdd()) { 33 | $this->oldLine = $oldLine; 34 | } 35 | if (! $type->isRemove()) { 36 | $this->newLine = $newLine; 37 | } 38 | } 39 | 40 | public function getOldLineNumber(): ?int { 41 | return $this->oldLine; 42 | } 43 | 44 | public function getNewLineNumber(): ?int { 45 | return $this->newLine; 46 | } 47 | 48 | public function getType(): DiffLineType { 49 | return $this->type; 50 | } 51 | 52 | public function getLine(): string { 53 | return $this->line; 54 | } 55 | 56 | public function __toString(): string { 57 | $oldLine = $this->oldLine ?? 'none'; 58 | $newLine = $this->newLine ?? 'none'; 59 | $type = (string)$this->type; 60 | return "({$type}) {$oldLine} => {$newLine}: {$this->line}"; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /PhpcsChanged/CacheEntry.php: -------------------------------------------------------------------------------- 1 | $this->path, 36 | 'hash' => $this->hash, 37 | 'type' => $this->type, 38 | 'phpcsStandard' => $this->phpcsStandard, 39 | 'data' => $this->data, 40 | ]; 41 | } 42 | 43 | public static function fromJson(array $deserializedJson): self { 44 | $entry = new CacheEntry(); 45 | $entry->path = $deserializedJson['path']; 46 | $entry->hash = $deserializedJson['hash']; 47 | $entry->type = $deserializedJson['type']; 48 | $entry->phpcsStandard = $deserializedJson['phpcsStandard']; 49 | $entry->data = $deserializedJson['data']; 50 | return $entry; 51 | } 52 | 53 | public function __toString(): string { 54 | return "Cache entry for file '{$this->path}', type '{$this->type}', hash '{$this->hash}', standard '{$this->phpcsStandard}': {$this->data}"; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sirbrillig/phpcs-changed", 3 | "description": "Run phpcs on files, but only report warnings/errors from lines which were changed.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Payton Swick", 9 | "email": "payton@foolord.com" 10 | } 11 | ], 12 | "minimum-stability": "dev", 13 | "prefer-stable": true, 14 | "scripts": { 15 | "precommit": "composer test && composer lint && composer static-analysis", 16 | "test": "./vendor/bin/phpunit --color=always --verbose", 17 | "lint": "./vendor/bin/phpcs -s PhpcsChanged bin tests index.php", 18 | "psalm": "./vendor/bin/psalm --no-cache bin/phpcs-changed", 19 | "static-analysis": "composer psalm" 20 | }, 21 | "bin": ["bin/phpcs-changed"], 22 | "config": { 23 | "sort-order": true, 24 | "allow-plugins": { 25 | "dealerdirect/phpcodesniffer-composer-installer": true 26 | } 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "PhpcsChanged\\": "PhpcsChanged/" 31 | }, 32 | "files": ["PhpcsChanged/Cli.php", "PhpcsChanged/functions.php"] 33 | }, 34 | "require": { 35 | "php": "^7.1 || ^8.0" 36 | }, 37 | "require-dev": { 38 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1 || ^1.0.0", 39 | "phpunit/phpunit": "^6.4 || ^7.0 || ^8.0 || ^9.5", 40 | "squizlabs/php_codesniffer": "^3.2.1", 41 | "sirbrillig/phpcs-variable-analysis": "^2.1.3", 42 | "vimeo/psalm": "^4.24 || ^5.0 || ^6.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/CacheManagerTest.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'standard', 16 | '5', 17 | '5', 18 | 'standard' 19 | ], 20 | 'non-default error and warning severity produces changed key' => [ 21 | 'standard', 22 | '1', 23 | '1', 24 | 'standard:w1e1' 25 | ], 26 | 'empty warning severity key gets replaced by default value of 5' => [ 27 | 'standard', 28 | '', 29 | '1', 30 | 'standard:w5e1', 31 | ], 32 | 'empty error severity key gets replaced by default value of 5' => [ 33 | 'standard', 34 | '1', 35 | '', 36 | 'standard:w1e5', 37 | ], 38 | 'empty error and warning severity key returns unchanged standard value' => [ 39 | 'standard', 40 | '', 41 | '', 42 | 'standard' 43 | ] 44 | ]; 45 | } 46 | 47 | /** 48 | * @dataProvider providePhpcsStandardCacheKeyGenerationData 49 | */ 50 | public function testPhpcsStandardCacheKeyGeneration( $phpcsStandard, $warningSeverity, $errorSeverity, $expected ) { 51 | $cache = new CacheManager( new TestCache() ); 52 | 53 | $phpcsStandardCacheKey = $cache->getPhpcsStandardCacheKey( $phpcsStandard, $warningSeverity, $errorSeverity ); 54 | 55 | $this->assertEquals( $expected, $phpcsStandardCacheKey ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /PhpcsChanged/ShellOperator.php: -------------------------------------------------------------------------------- 1 | disabled) { 34 | return new CacheObject(); 35 | } 36 | $this->didSave = false; 37 | $cacheObject = new CacheObject(); 38 | $cacheObject->cacheVersion = $this->cacheVersion ?? getVersion(); 39 | foreach(array_values($this->savedFileData) as $entry) { 40 | $cacheObject->entries[] = CacheEntry::fromJson($entry); 41 | } 42 | return $cacheObject; 43 | } 44 | 45 | public function save(CacheObject $cacheObject): void { 46 | if ($this->disabled) { 47 | return; 48 | } 49 | $this->didSave = true; 50 | $this->setCacheVersion($cacheObject->cacheVersion); 51 | $this->savedFileData = []; 52 | foreach($cacheObject->entries as $entry) { 53 | $this->setEntry($entry->path, $entry->type, $entry->hash, $entry->phpcsStandard, $entry->data); 54 | } 55 | } 56 | 57 | public function setEntry(string $path, string $type, string $hash, string $phpcsStandard, string $data): void { 58 | $this->savedFileData[] = [ 59 | 'path' => $path, 60 | 'hash' => $hash, 61 | 'data' => $data, 62 | 'type' => $type, 63 | 'phpcsStandard' => $phpcsStandard, 64 | ]; 65 | } 66 | 67 | public function setCacheVersion(string $cacheVersion): void { 68 | $this->cacheVersion = $cacheVersion; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/CliTest.php: -------------------------------------------------------------------------------- 1 | isFile = $isFile; 13 | } 14 | 15 | public function isFile(): bool { 16 | return $this->isFile; 17 | } 18 | } 19 | 20 | final class CliTest extends TestCase { 21 | public function filesProvider() { 22 | return [ 23 | 'PHP File' => ['example.php', true, true], 24 | 'Dir' => ['example', false, false], 25 | 'JS File' => ['example.js', true, true], 26 | 'INC file' => ['example.inc', true, true], 27 | 'Dot File' => ['.example', true, false], 28 | 'Dot INC dot PHP' => ['example.inc.php', true, true], 29 | ]; 30 | } 31 | 32 | /** 33 | * @dataProvider filesProvider 34 | */ 35 | public function testFileHasValidExtension( $fileName, $isFile, $hasValidExtension ) { 36 | 37 | $file = new MockSplFileInfo($fileName); 38 | $file->setIsFile($isFile); 39 | $this->assertEquals(fileHasValidExtension($file), $hasValidExtension); 40 | } 41 | 42 | public function ignoreProvider(): array { 43 | return [ 44 | ['bin/*', 'bin', true], 45 | ['*.php', 'bin/foobar.php', true], 46 | ['.php', 'bin/foobar.php', true], 47 | ['foobar.php', 'bin/foobar.php', true], 48 | ['.inc', 'bin/foobar.php', false], 49 | ['bar.php', 'foo.php', false], 50 | ['bar.phpfoo.php', 'bin/foobar.php', false], 51 | ['foobar.php,bin/', 'bin/foo.php', true], 52 | ]; 53 | } 54 | 55 | /** 56 | * @dataProvider ignoreProvider 57 | */ 58 | public function testShouldIgnorePath(string $pattern, string $path, bool $expected): void { 59 | $this->assertEquals($expected, shouldIgnorePath($path, $pattern)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /PhpcsChanged/LintMessage.php: -------------------------------------------------------------------------------- 1 | Additional message properties (message, source, column, severity, fixable, etc.) 24 | */ 25 | private $otherProperties; 26 | 27 | public function __construct(int $line, ?string $file, string $type, array $otherProperties) { 28 | $this->line = $line; 29 | $this->file = $file; 30 | $this->type = $type; 31 | $this->otherProperties = $otherProperties; 32 | } 33 | 34 | public function getLineNumber(): int { 35 | return $this->line; 36 | } 37 | 38 | public function getFile(): ?string { 39 | return $this->file; 40 | } 41 | 42 | public function setFile(string $file): void { 43 | $this->file = $file; 44 | } 45 | 46 | public function getType(): string { 47 | return $this->type; 48 | } 49 | 50 | public function getMessage(): string { 51 | return $this->otherProperties['message'] ?? ''; 52 | } 53 | 54 | public function getSource(): string { 55 | return $this->otherProperties['source'] ?? ''; 56 | } 57 | 58 | public function getColumn(): int { 59 | return $this->otherProperties['column'] ?? 0; 60 | } 61 | 62 | public function getSeverity(): int { 63 | return $this->otherProperties['severity'] ?? 5; 64 | } 65 | 66 | public function getFixable(): bool { 67 | return $this->otherProperties['fixable'] ?? false; 68 | } 69 | 70 | /** 71 | * @return string|int|bool|float|null 72 | */ 73 | public function getProperty( string $key ) { 74 | return $this->otherProperties[$key]; 75 | } 76 | 77 | public function getOtherProperties(): array { 78 | return $this->otherProperties; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/helpers/GitFixture.php: -------------------------------------------------------------------------------- 1 | getFormattedMessages($messages, []); 44 | } 45 | 46 | public static function fromArrays(array $messages, ?string $fileName = null): PhpcsMessages { 47 | return new PhpcsMessages(array_map(function(array $messageArray) use ($fileName) { 48 | return new LintMessage($messageArray['line'] ?? 0, $fileName, $messageArray['type'] ?? 'ERROR', $messageArray); 49 | }, $messages)); 50 | } 51 | 52 | public static function messageToPhpcsArray(LintMessage $message): array { 53 | return array_merge([ 54 | 'line' => $message->getLineNumber(), 55 | ], $message->getOtherProperties()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/csqa.yml: -------------------------------------------------------------------------------- 1 | name: CS and QA 2 | 3 | on: 4 | # Run on all pushes and on all pull requests. 5 | # Prevent the build from running when there are only irrelevant changes. 6 | push: 7 | paths-ignore: 8 | - '**.md' 9 | pull_request: 10 | # Allow manually triggering the workflow. 11 | workflow_dispatch: 12 | 13 | # Cancels all previous workflow runs for the same branch that have not yet completed. 14 | concurrency: 15 | # The concurrency group contains the workflow name and the branch name. 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | checkcs: 21 | name: 'PHP: 7.4 | Basic CS and QA checks' 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v3 27 | 28 | - name: Install PHP 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: '7.4' 32 | coverage: none 33 | tools: cs2pr 34 | 35 | # Using PHPCS `3.x-dev` as an early detection system for bugs upstream. 36 | - name: 'Composer: adjust dependencies' 37 | run: composer require --no-update squizlabs/php_codesniffer:"3.x-dev" 38 | 39 | # Install dependencies and handle caching in one go. 40 | # @link https://github.com/marketplace/actions/install-composer-dependencies 41 | - name: Install Composer dependencies 42 | uses: "ramsey/composer-install@v2" 43 | 44 | # Check the code-style consistency of the PHP files. 45 | - name: Check PHP code style 46 | continue-on-error: true 47 | run: composer lint -- --no-cache --report-full --report-checkstyle=./phpcs-report.xml 48 | 49 | - name: Show PHPCS results in PR 50 | run: cs2pr ./phpcs-report.xml 51 | 52 | staticanalysis: 53 | name: "PHP: 7.4 | Static analysis" 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v3 59 | 60 | - name: Install PHP 61 | uses: shivammathur/setup-php@v2 62 | with: 63 | php-version: '7.4' 64 | coverage: none 65 | 66 | # Install dependencies and handle caching in one go. 67 | # Dependencies need to be installed to make sure the PHPUnit classes are recognized. 68 | # @link https://github.com/marketplace/actions/install-composer-dependencies 69 | - name: Install Composer dependencies 70 | uses: "ramsey/composer-install@v2" 71 | 72 | - name: Run Static analysis 73 | run: composer static-analysis 74 | -------------------------------------------------------------------------------- /PhpcsChanged/CheckstyleReporter.php: -------------------------------------------------------------------------------- 1 | getFile() ?? 'STDIN'; 15 | }, $messages->getMessages())); 16 | if (count($files) === 0) { 17 | $files = ['STDIN']; 18 | } 19 | 20 | $outputByFile = array_reduce($files, function(string $output, string $file) use ($messages): string { 21 | $messagesForFile = array_values(array_filter($messages->getMessages(), static function(LintMessage $message) use ($file): bool { 22 | return ($message->getFile() ?? 'STDIN') === $file; 23 | })); 24 | $output .= $this->getFormattedMessagesForFile($messagesForFile, $file); 25 | return $output; 26 | }, ''); 27 | 28 | $output = "\n"; 29 | $output .= "\n"; 30 | $output .= $outputByFile; 31 | $output .= "\n"; 32 | 33 | return $output; 34 | } 35 | 36 | private function getFormattedMessagesForFile(array $messages, string $file): string { 37 | if (count($messages) === 0) { 38 | return ''; 39 | } 40 | 41 | $xmlOutputForFile = "\t\n"; 42 | $xmlOutputForFile .= array_reduce($messages, function(string $output, LintMessage $message): string { 43 | $line = $message->getLineNumber(); 44 | $column = $message->getColumn(); 45 | $source = $this->escapeXml($message->getSource()); 46 | $messageText = $this->escapeXml($message->getMessage()); 47 | $type = $message->getType(); 48 | 49 | // Map phpcs types to Checkstyle severity levels 50 | $severity = $type === 'ERROR' ? 'error' : 'warning'; 51 | 52 | $output .= sprintf( 53 | "\t\t\n", 54 | $line, 55 | $column, 56 | $severity, 57 | $messageText, 58 | $source 59 | ); 60 | return $output; 61 | }, ''); 62 | $xmlOutputForFile .= "\t\n"; 63 | 64 | return $xmlOutputForFile; 65 | } 66 | 67 | private function escapeXml(string $string): string { 68 | return htmlspecialchars($string, ENT_XML1 | ENT_QUOTES, 'UTF-8'); 69 | } 70 | 71 | #[\Override] 72 | public function getExitCode(PhpcsMessages $messages): int { 73 | return (count($messages->getMessages()) > 0) ? 1 : 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /PhpcsChanged/JsonReporter.php: -------------------------------------------------------------------------------- 1 | getFile() ?? 'STDIN'; 16 | }, $messages->getMessages())); 17 | if (count($files) === 0) { 18 | $files = ['STDIN']; 19 | } 20 | 21 | $outputByFile = array_map(function(string $file) use ($messages): array { 22 | $messagesForFile = array_values(array_filter($messages->getMessages(), function(LintMessage $message) use ($file): bool { 23 | return ($message->getFile() ?? 'STDIN') === $file; 24 | })); 25 | return $this->getFormattedMessagesForFile($messagesForFile, $file); 26 | }, $files); 27 | 28 | $errors = array_values(array_filter($messages->getMessages(), function($message) { 29 | return $message->getType() === 'ERROR'; 30 | })); 31 | $warnings = array_values(array_filter($messages->getMessages(), function($message) { 32 | return $message->getType() === 'WARNING'; 33 | })); 34 | $dataForJson = [ 35 | 'totals' => [ 36 | 'errors' => count($errors), 37 | 'warnings' => count($warnings), 38 | 'fixable' => 0, 39 | ], 40 | 'files' => array_merge([], ...$outputByFile), 41 | ]; 42 | $output = json_encode($dataForJson, JSON_UNESCAPED_SLASHES); 43 | if ($output === false) { 44 | throw new \Exception('Failed to JSON-encode result messages'); 45 | } 46 | return $output; 47 | } 48 | 49 | private function getFormattedMessagesForFile(array $messages, string $file): array { 50 | $errors = array_values(array_filter($messages, function($message) { 51 | return $message->getType() === 'ERROR'; 52 | })); 53 | $warnings = array_values(array_filter($messages, function($message) { 54 | return $message->getType() === 'WARNING'; 55 | })); 56 | $messageArrays = array_map(function(LintMessage $message): array { 57 | return PhpcsMessagesHelpers::messageToPhpcsArray($message); 58 | }, $messages); 59 | $dataForJson = [ 60 | $file => [ 61 | 'errors' => count($errors), 62 | 'warnings' => count($warnings), 63 | 'messages' => $messageArrays, 64 | ], 65 | ]; 66 | return $dataForJson; 67 | } 68 | 69 | #[\Override] 70 | public function getExitCode(PhpcsMessages $messages): int { 71 | return (count($messages->getMessages()) > 0) ? 1 : 0; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/helpers/SvnFixture.php: -------------------------------------------------------------------------------- 1 | cacheFilePath)) { 21 | return new CacheObject(); 22 | } 23 | $contents = file_get_contents($this->cacheFilePath); 24 | if ($contents === false) { 25 | throw new \Exception('Failed to read cache file'); 26 | } 27 | /** @var array{cacheVersion: string, entries: Array>} */ 28 | $decoded = json_decode($contents, true); 29 | if (! $this->isDecodedDataValid($decoded)) { 30 | throw new \Exception('Invalid cache file'); 31 | } 32 | $cacheObject = new CacheObject(); 33 | $cacheObject->cacheVersion = $decoded['cacheVersion']; 34 | foreach($decoded['entries'] as $entry) { 35 | if (! $this->isDecodedEntryValid($entry)) { 36 | throw new \Exception('Invalid cache file entry: ' . var_export($entry, true)); 37 | } 38 | $cacheObject->entries[] = CacheEntry::fromJson($entry); 39 | } 40 | return $cacheObject; 41 | } 42 | 43 | #[\Override] 44 | public function save(CacheObject $cacheObject): void { 45 | $data = [ 46 | 'cacheVersion' => $cacheObject->cacheVersion, 47 | 'entries' => $cacheObject->entries, 48 | ]; 49 | $encodedData = json_encode($data); 50 | if ($encodedData === false) { 51 | throw new \Exception('Failed to write cache file; encoding failed'); 52 | } 53 | $result = file_put_contents($this->cacheFilePath, $encodedData); 54 | if ($result === false) { 55 | throw new \Exception('Failed to write cache file'); 56 | } 57 | } 58 | 59 | /** 60 | * @param mixed $decoded The json-decoded data 61 | */ 62 | private function isDecodedDataValid($decoded): bool { 63 | if (! is_array($decoded) || 64 | ! array_key_exists('cacheVersion', $decoded) || 65 | ! array_key_exists('entries', $decoded) || 66 | ! is_array($decoded['entries']) 67 | ) { 68 | return false; 69 | } 70 | if (! is_string($decoded['cacheVersion'])) { 71 | return false; 72 | } 73 | // Note that this does not validate the entries to avoid iterating over 74 | // them twice. That should be done by isDecodedEntryValid. 75 | return true; 76 | } 77 | 78 | private function isDecodedEntryValid(array $entry): bool { 79 | if (! array_key_exists('path', $entry) || ! array_key_exists('data', $entry) || ! array_key_exists('phpcsStandard', $entry) || ! array_key_exists('hash', $entry) || ! array_key_exists('type', $entry)) { 80 | return false; 81 | } 82 | return true; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /PhpcsChanged/FullReporter.php: -------------------------------------------------------------------------------- 1 | getFile() ?? 'STDIN'; 16 | }, $messages->getMessages())); 17 | if (count($files) === 0) { 18 | $files = ['STDIN']; 19 | } 20 | 21 | $lineCount = count($messages->getMessages()); 22 | if ($lineCount < 1) { 23 | return ''; 24 | } 25 | 26 | return implode("\n", array_filter(array_map(function(string $file) use ($messages, $options): ?string { 27 | $messagesForFile = array_values(array_filter($messages->getMessages(), function(LintMessage $message) use ($file): bool { 28 | return ($message->getFile() ?? 'STDIN') === $file; 29 | })); 30 | return $this->getFormattedMessagesForFile($messagesForFile, $file, $options); 31 | }, $files))); 32 | } 33 | 34 | private function getFormattedMessagesForFile(array $messages, string $file, array $options): ?string { 35 | $lineCount = count($messages); 36 | if ($lineCount < 1) { 37 | return null; 38 | } 39 | $errorsCount = count(array_values(array_filter($messages, function($message) { 40 | return $message->getType() === 'ERROR'; 41 | }))); 42 | $warningsCount = count(array_values(array_filter($messages, function($message) { 43 | return $message->getType() === 'WARNING'; 44 | }))); 45 | 46 | $linePlural = ($lineCount === 1) ? '' : 'S'; 47 | $errorPlural = ($errorsCount === 1) ? '' : 'S'; 48 | $warningPlural = ($warningsCount === 1) ? '' : 'S'; 49 | 50 | $longestNumber = getLongestString(array_map(function(LintMessage $message): int { 51 | return $message->getLineNumber(); 52 | }, $messages)); 53 | 54 | $formattedLines = implode("\n", array_map(function(LintMessage $message) use ($longestNumber, $options): string { 55 | $source = $message->getSource() ?: 'Unknown'; 56 | $sourceString = array_key_exists('s', $options) ? " ({$source})" : ''; 57 | return sprintf(" %{$longestNumber}d | %s | %s%s", $message->getLineNumber(), $message->getType(), $message->getMessage(), $sourceString); 58 | }, $messages)); 59 | 60 | return <<getMessages()) > 0) ? 1 : 0; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /PhpcsChanged/XmlReporter.php: -------------------------------------------------------------------------------- 1 | options = $options; 27 | $this->shell = $shell; 28 | } 29 | 30 | #[\Override] 31 | public function getFormattedMessages(PhpcsMessages $messages, array $options): string { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable 32 | $files = array_unique(array_map(function(LintMessage $message): string { 33 | return $message->getFile() ?? 'STDIN'; 34 | }, $messages->getMessages())); 35 | if (count($files) === 0) { 36 | $files = ['STDIN']; 37 | } 38 | 39 | $outputByFile = array_reduce($files,function(string $output, string $file) use ($messages): string { 40 | $messagesForFile = array_values(array_filter($messages->getMessages(), static function(LintMessage $message) use ($file): bool { 41 | return ($message->getFile() ?? 'STDIN') === $file; 42 | })); 43 | $output .= $this->getFormattedMessagesForFile($messagesForFile, $file); 44 | return $output; 45 | }, ''); 46 | 47 | $phpcsVersion = $this->shell->getPhpcsVersion(); 48 | 49 | $output = "\n"; 50 | $output .= "\n"; 51 | $output .= $outputByFile; 52 | $output .= "\n"; 53 | 54 | return $output; 55 | } 56 | 57 | private function getFormattedMessagesForFile(array $messages, string $file): string { 58 | $errorCount = count( array_values(array_filter($messages, function(LintMessage $message) { 59 | return $message->getType() === 'ERROR'; 60 | }))); 61 | $warningCount = count(array_values(array_filter($messages, function(LintMessage $message) { 62 | return $message->getType() === 'WARNING'; 63 | }))); 64 | $fixableCount = count(array_values(array_filter($messages, function(LintMessage $message) { 65 | return $message->getFixable(); 66 | }))); 67 | $xmlOutputForFile = "\t\n"; 68 | $xmlOutputForFile .= array_reduce($messages, function(string $output, LintMessage $message): string{ 69 | $type = strtolower( $message->getType() ); 70 | $line = $message->getLineNumber(); 71 | $column = $message->getColumn(); 72 | $source = $message->getSource(); 73 | $severity = $message->getSeverity(); 74 | $fixable = $message->getFixable() ? "1" : "0"; 75 | $messageString = $message->getMessage(); 76 | $output .= "\t\t<{$type} line=\"{$line}\" column=\"{$column}\" source=\"{$source}\" severity=\"{$severity}\" fixable=\"{$fixable}\">{$messageString}\n"; 77 | return $output; 78 | },''); 79 | $xmlOutputForFile .= "\t\n"; 80 | 81 | return $xmlOutputForFile; 82 | } 83 | 84 | #[\Override] 85 | public function getExitCode(PhpcsMessages $messages): int { 86 | return (count($messages->getMessages()) > 0) ? 1 : 0; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/helpers/TestShell.php: -------------------------------------------------------------------------------- 1 | registerReadableFileName($fileName); 30 | } 31 | parent::__construct($options); 32 | } 33 | 34 | public function registerExecutable(string $path): void { 35 | $this->executables[$path] = true; 36 | } 37 | 38 | public function registerReadableFileName(string $fileName, bool $override = false): bool { 39 | if (!isset($this->readableFileNames[$fileName]) || $override ) { 40 | $this->readableFileNames[$fileName] = true; 41 | return true; 42 | } 43 | throw new \Exception("Already registered file name: {$fileName}"); 44 | } 45 | 46 | public function registerCommand(string $command, string $output, int $return_val = 0, bool $override = false): bool { 47 | if (!isset($this->commands[$command]) || $override) { 48 | $this->commands[$command] = [ 49 | 'output' => $output, 50 | 'return_val' => $return_val, 51 | ]; 52 | return true; 53 | } 54 | throw new \Exception("Already registered command: {$command}"); 55 | } 56 | 57 | public function deregisterCommand(string $command): bool { 58 | if (isset($this->commands[$command])) { 59 | unset($this->commands[$command]); 60 | return true; 61 | } 62 | throw new \Exception("No registered command: {$command}"); 63 | } 64 | 65 | public function setFileHash(string $fileName, string $hash): void { 66 | $this->fileHashes[$fileName] = $hash; 67 | } 68 | 69 | public function isReadable(string $fileName): bool { 70 | return isset($this->readableFileNames[$fileName]); 71 | } 72 | 73 | public function exitWithCode(int $code): void {} // phpcs:ignore VariableAnalysis 74 | 75 | public function printError(string $message): void {} // phpcs:ignore VariableAnalysis 76 | 77 | public function validateExecutableExists(string $name, string $command): void { 78 | if (isset($this->executables[$command])) { 79 | return; 80 | } 81 | throw new \Exception("The executable for {$name} with the path '{$command}' has not been mocked."); 82 | } 83 | 84 | public function getFileHash(string $fileName): string { 85 | return $this->fileHashes[$fileName] ?? $fileName; 86 | } 87 | 88 | protected function executeCommand(string $command, ?int &$return_val = null): string { 89 | foreach ($this->commands as $registeredCommand => $return) { 90 | if ($registeredCommand === substr($command, 0, strlen($registeredCommand)) ) { 91 | $return_val = $return['return_val']; 92 | $this->commandsCalled[$registeredCommand] = $command; 93 | return $return['output']; 94 | } 95 | } 96 | 97 | throw new \Exception("Unknown command: {$command}"); 98 | } 99 | 100 | public function resetCommandsCalled(): void { 101 | $this->commandsCalled = []; 102 | } 103 | 104 | public function wasCommandCalled(string $registeredCommand): bool { 105 | return isset($this->commandsCalled[$registeredCommand]); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/PhpcsMessagesTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $messages->getLineNumbers()); 15 | } 16 | 17 | public function testFromPhpcsJsonWithEmptyJson() { 18 | $expected = []; 19 | $json = ''; 20 | $messages = PhpcsMessages::fromPhpcsJson($json); 21 | $this->assertEquals($expected, $messages->getLineNumbers()); 22 | } 23 | 24 | public function testGetPhpcsJson() { 25 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"STDIN":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}'; 26 | $messages = PhpcsMessages::fromArrays([ 27 | [ 28 | 'type' => 'WARNING', 29 | 'severity' => 5, 30 | 'fixable' => false, 31 | 'column' => 5, 32 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 33 | 'line' => 20, 34 | 'message' => 'Found unused symbol Emergent.', 35 | ], 36 | ]); 37 | $this->assertEquals($expected, $messages->toPhpcsJson()); 38 | } 39 | 40 | public function testMerge() { 41 | $expected = PhpcsMessages::fromArrays([ 42 | [ 43 | 'type' => 'WARNING', 44 | 'severity' => 5, 45 | 'fixable' => false, 46 | 'column' => 5, 47 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 48 | 'line' => 15, 49 | 'message' => 'Found unused symbol Foo.', 50 | ], 51 | [ 52 | 'type' => 'WARNING', 53 | 'severity' => 5, 54 | 'fixable' => false, 55 | 'column' => 5, 56 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 57 | 'line' => 18, 58 | 'message' => 'Found unused symbol Baz.', 59 | ], 60 | [ 61 | 'type' => 'WARNING', 62 | 'severity' => 5, 63 | 'fixable' => false, 64 | 'column' => 5, 65 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 66 | 'line' => 20, 67 | 'message' => 'Found unused symbol Bar.', 68 | ], 69 | ]); 70 | $messagesA = PhpcsMessages::fromArrays([ 71 | [ 72 | 'type' => 'WARNING', 73 | 'severity' => 5, 74 | 'fixable' => false, 75 | 'column' => 5, 76 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 77 | 'line' => 15, 78 | 'message' => 'Found unused symbol Foo.', 79 | ], 80 | ]); 81 | $messagesB = PhpcsMessages::fromArrays([ 82 | [ 83 | 'type' => 'WARNING', 84 | 'severity' => 5, 85 | 'fixable' => false, 86 | 'column' => 5, 87 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 88 | 'line' => 18, 89 | 'message' => 'Found unused symbol Baz.', 90 | ], 91 | ]); 92 | $messagesC = PhpcsMessages::fromArrays([ 93 | [ 94 | 'type' => 'WARNING', 95 | 'severity' => 5, 96 | 'fixable' => false, 97 | 'column' => 5, 98 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 99 | 'line' => 20, 100 | 'message' => 'Found unused symbol Bar.', 101 | ], 102 | ]); 103 | $messages = PhpcsMessages::merge([$messagesA, $messagesB, $messagesC]); 104 | $this->assertEquals($expected->getMessages(), $messages->getMessages()); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | # Run on all pushes and pull requests. 5 | push: 6 | pull_request: 7 | # Allow manually triggering the workflow. 8 | workflow_dispatch: 9 | 10 | # Cancels all previous workflow runs for the same branch that have not yet completed. 11 | concurrency: 12 | # The concurrency group contains the workflow name and the branch name. 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | #### TEST STAGE #### 18 | test: 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | # The GHA matrix works different from Travis. 24 | # You can define jobs here and then augment them with extra variables in `include`, 25 | # as well as add extra jobs in `include`. 26 | # @link https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix 27 | # 28 | # IMPORTANT: test runs shouldn't fail because of PHPCS being incompatible with a PHP version. 29 | # - PHPCS will run without errors on PHP 5.4 - 7.4 on all supported PHPCS versions. 30 | # - PHP 8.0 needs PHPCS 3.5.7+ to run without errors. 31 | # - PHP 8.1 needs PHPCS 3.6.1+ to run without errors. 32 | php: ['7.1', '7.2', '7.3'] 33 | phpcs_version: ['3.5.6', '3.x-dev'] 34 | 35 | include: 36 | # Make the matrix complete without duplicating builds run in code coverage. 37 | - php: '8.1' 38 | phpcs_version: '3.6.1' 39 | 40 | - php: '8.0' 41 | phpcs_version: '3.x-dev' 42 | - php: '8.0' 43 | phpcs_version: '3.5.7' 44 | 45 | - php: '7.4' 46 | phpcs_version: '3.x-dev' 47 | 48 | # Experimental builds. 49 | - php: '8.2' # Nightly. 50 | phpcs_version: '3.x-dev' 51 | 52 | name: "Test: PHP ${{ matrix.php }} on PHPCS ${{ matrix.phpcs_version }}" 53 | 54 | continue-on-error: ${{ matrix.php == '8.2' }} 55 | 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v3 59 | 60 | - name: Setup ini config 61 | id: set_ini 62 | run: | 63 | # On stable PHPCS versions, allow for PHP deprecation notices. 64 | # Unit tests don't need to fail on those for stable releases where those issues won't get fixed anymore. 65 | if [ "${{ matrix.phpcs_version }}" != "3.x-dev" ]; then 66 | echo '::set-output name=PHP_INI::error_reporting=E_ALL & ~E_DEPRECATED, display_errors=On, zend.assertions=1' 67 | else 68 | echo '::set-output name=PHP_INI::error_reporting=-1, display_errors=On, zend.assertions=1' 69 | fi 70 | 71 | - name: Install PHP 72 | uses: shivammathur/setup-php@v2 73 | with: 74 | php-version: ${{ matrix.php }} 75 | ini-values: ${{ steps.set_ini.outputs.PHP_INI }} 76 | coverage: none 77 | 78 | - name: 'Composer: adjust dependencies' 79 | run: | 80 | # Set the PHPCS version for this test run. 81 | composer require --no-update squizlabs/php_codesniffer:"${{ matrix.phpcs_version }}" 82 | 83 | # Install dependencies and handle caching in one go. 84 | # @link https://github.com/marketplace/actions/install-composer-dependencies 85 | - name: Install Composer dependencies - normal 86 | if: ${{ matrix.php != '8.2' }} 87 | uses: "ramsey/composer-install@v2" 88 | 89 | # For the PHP "nightly", we need to install with ignore platform reqs as not all dependencies allow it yet. 90 | - name: Install Composer dependencies - with ignore platform 91 | if: ${{ matrix.php == '8.2' }} 92 | uses: "ramsey/composer-install@v2" 93 | with: 94 | composer-options: --ignore-platform-req=php 95 | 96 | - name: Run the unit tests 97 | run: composer test 98 | -------------------------------------------------------------------------------- /PhpcsChanged/JunitReporter.php: -------------------------------------------------------------------------------- 1 | getFile() ?? 'STDIN'; 15 | }, $messages->getMessages())); 16 | if (count($files) === 0) { 17 | $files = ['STDIN']; 18 | } 19 | 20 | $totalTests = count($messages->getMessages()); 21 | $totalFailures = count(array_filter($messages->getMessages(), function(LintMessage $message): bool { 22 | return $message->getType() === 'WARNING'; 23 | })); 24 | $totalErrors = count(array_filter($messages->getMessages(), function(LintMessage $message): bool { 25 | return $message->getType() === 'ERROR'; 26 | })); 27 | 28 | // Calculate total time from all files 29 | $totalTime = array_sum($messages->getAllTiming()); 30 | 31 | $outputByFile = array_reduce($files, function(string $output, string $file) use ($messages): string { 32 | $messagesForFile = array_values(array_filter($messages->getMessages(), static function(LintMessage $message) use ($file): bool { 33 | return ($message->getFile() ?? 'STDIN') === $file; 34 | })); 35 | $output .= $this->getFormattedMessagesForFile($messagesForFile, $file, $messages); 36 | return $output; 37 | }, ''); 38 | 39 | $output = "\n"; 40 | $output .= sprintf("\n", $totalTests, $totalFailures, $totalErrors, $totalTime); 41 | $output .= $outputByFile; 42 | $output .= "\n"; 43 | 44 | return $output; 45 | } 46 | 47 | private function getFormattedMessagesForFile(array $messages, string $file, PhpcsMessages $allMessages): string { 48 | $testCount = count($messages); 49 | $errorCount = count(array_values(array_filter($messages, function(LintMessage $message) { 50 | return $message->getType() === 'ERROR'; 51 | }))); 52 | $failureCount = count(array_values(array_filter($messages, function(LintMessage $message) { 53 | return $message->getType() === 'WARNING'; 54 | }))); 55 | 56 | // Get timing for this specific file 57 | $fileTime = $allMessages->getTiming($file); 58 | 59 | $xmlOutputForFile = sprintf("\t\n", 60 | $file, $testCount, $failureCount, $errorCount, $fileTime); 61 | $xmlOutputForFile .= array_reduce($messages, function(string $output, LintMessage $message): string { 62 | $line = $message->getLineNumber(); 63 | $column = $message->getColumn(); 64 | $source = $this->escapeXml($message->getSource()); 65 | $messageText = $this->escapeXml($message->getMessage()); 66 | $type = $message->getType(); 67 | $severity = $message->getSeverity(); 68 | 69 | // Create a unique test case name using line:column and source 70 | $testCaseName = "line {$line}, column {$column}"; 71 | $output .= "\t\t\n"; 72 | 73 | if ($type === 'ERROR') { 74 | $output .= "\t\t\tLine {$line}, Column {$column}: {$messageText} (Severity: {$severity})\n"; 75 | } else { 76 | $output .= "\t\t\tLine {$line}, Column {$column}: {$messageText} (Severity: {$severity})\n"; 77 | } 78 | 79 | $output .= "\t\t\n"; 80 | return $output; 81 | }, ''); 82 | $xmlOutputForFile .= "\t\n"; 83 | 84 | return $xmlOutputForFile; 85 | } 86 | 87 | private function escapeXml(string $string): string { 88 | return htmlspecialchars($string, ENT_XML1 | ENT_QUOTES, 'UTF-8'); 89 | } 90 | 91 | #[\Override] 92 | public function getExitCode(PhpcsMessages $messages): int { 93 | return (count($messages->getMessages()) > 0) ? 1 : 0; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /PhpcsChanged/DiffLineMap.php: -------------------------------------------------------------------------------- 1 | diffLines = $diffLines; 14 | } 15 | 16 | public function getOldLineNumberForLine(int $lineNumber): ?int { 17 | foreach ($this->diffLines as $diffLine) { 18 | if ($diffLine->getNewLineNumber() === $lineNumber) { 19 | return $diffLine->getOldLineNumber(); 20 | } 21 | } 22 | // go through each changed line in the new file (each DiffLine of type context or add) 23 | // if the new line number is greater than the line number we are looking for 24 | // then add the last difference between the old and new lines to the line number we are looking for 25 | $lineNumberDelta = 0; 26 | $lastOldLine = 0; 27 | $lastNewLine = 0; 28 | foreach ($this->diffLines as $diffLine) { 29 | $lastOldLine = $diffLine->getOldLineNumber() ?? $lastOldLine; 30 | $lastNewLine = $diffLine->getNewLineNumber() ?? $lastNewLine; 31 | if ($diffLine->getType()->isRemove()) { 32 | continue; 33 | } 34 | if (($diffLine->getNewLineNumber() ?? 0) > $lineNumber) { 35 | return intval( $lineNumber + $lineNumberDelta ); 36 | } 37 | $lineNumberDelta = ($diffLine->getOldLineNumber() ?? 0) - ($diffLine->getNewLineNumber() ?? 0); 38 | } 39 | return $lastOldLine + ($lineNumber - $lastNewLine); 40 | } 41 | 42 | public static function fromUnifiedDiff(string $unifiedDiff): DiffLineMap { 43 | $diffStringLines = preg_split("/\r\n|\n|\r/", $unifiedDiff) ?: []; 44 | $oldStartLine = $newStartLine = null; 45 | $currentOldLine = $currentNewLine = null; 46 | $lines = []; 47 | foreach ($diffStringLines as $diffStringLine) { 48 | 49 | // Find the start of a hunk 50 | $matches = []; 51 | if (1 === preg_match('/^@@ \-(\d+),(\d+) \+(\d+),(\d+) @@/', $diffStringLine, $matches)) { 52 | $oldStartLine = $matches[1] ?? null; 53 | $newStartLine = $matches[3] ?? null; 54 | $currentOldLine = $oldStartLine; 55 | $currentNewLine = $newStartLine; 56 | continue; 57 | } 58 | 59 | // Ignore headers 60 | if (self::isLineDiffHeader($diffStringLine)) { 61 | continue; 62 | } 63 | 64 | // Parse a hunk 65 | if ($oldStartLine !== null && $newStartLine !== null) { 66 | $lines[] = new DiffLine((int)$currentOldLine, (int)$currentNewLine, self::getDiffLineTypeForLine($diffStringLine), $diffStringLine); 67 | if (self::isLineDiffRemoval($diffStringLine)) { 68 | $currentOldLine ++; 69 | } else if (self::isLineDiffAddition($diffStringLine)) { 70 | $currentNewLine ++; 71 | } else { 72 | $currentOldLine ++; 73 | $currentNewLine ++; 74 | } 75 | } 76 | } 77 | return new DiffLineMap($lines); 78 | } 79 | 80 | public static function getFileNameFromDiff(string $unifiedDiff): ?string { 81 | $diffStringLines = preg_split("/\r\n|\n|\r/", $unifiedDiff) ?: []; 82 | foreach ($diffStringLines as $diffStringLine) { 83 | $matches = []; 84 | if (1 === preg_match('/^\+\+\+ (\S+)/', $diffStringLine, $matches)) { 85 | return $matches[1] ?? null; 86 | } 87 | } 88 | return null; 89 | } 90 | 91 | private static function getDiffLineTypeForLine(string $line): DiffLineType { 92 | if (self::isLineDiffRemoval($line)) { 93 | return DiffLineType::makeRemove(); 94 | } else if (self::isLineDiffAddition($line)) { 95 | return DiffLineType::makeAdd(); 96 | } 97 | return DiffLineType::makeContext(); 98 | } 99 | 100 | private static function isLineDiffHeader(string $line): bool { 101 | return (1 === preg_match('/^Index: /', $line) || 1 === preg_match('/^====/', $line) || 1 === preg_match('/^\-\-\-/', $line) || 1 === preg_match('/^\+\+\+/', $line)); 102 | } 103 | 104 | private static function isLineDiffRemoval(string $line): bool { 105 | return (1 === preg_match('/^\-/', $line)); 106 | } 107 | 108 | private static function isLineDiffAddition(string $line): bool { 109 | return (1 === preg_match('/^\+/', $line)); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /PhpcsChanged/LintMessages.php: -------------------------------------------------------------------------------- 1 | Per-file execution timing in seconds 17 | */ 18 | private $timingData = []; 19 | 20 | final public function __construct(array $messages) { 21 | foreach($messages as $message) { 22 | if (! $message instanceof LintMessage) { 23 | throw new \Exception('Each message in a LintMessages object must be a LintMessage; found ' . var_export($message, true)); 24 | } 25 | } 26 | $this->messages = $messages; 27 | } 28 | 29 | /** 30 | * @return static 31 | */ 32 | public static function merge(array $messages) { 33 | $merged = self::fromLintMessages(array_merge([], ...array_map(function(self $message) { 34 | return $message->getMessages(); 35 | }, $messages))); 36 | 37 | // Merge timing data from all message sets 38 | $mergedTiming = []; 39 | foreach ($messages as $messageSet) { 40 | $mergedTiming = array_merge($mergedTiming, $messageSet->getAllTiming()); 41 | } 42 | $merged->setAllTiming($mergedTiming); 43 | 44 | return $merged; 45 | } 46 | 47 | /** 48 | * @return static 49 | */ 50 | public static function fromLintMessages(array $messages, ?string $fileName = null) { 51 | return new static(array_map(function(LintMessage $message) use ($fileName) { 52 | if (is_string($fileName) && strlen($fileName) > 0) { 53 | $message->setFile($fileName); 54 | } 55 | return $message; 56 | }, $messages)); 57 | } 58 | 59 | /** 60 | * @return LintMessage[] 61 | */ 62 | public function getMessages(): array { 63 | return $this->messages; 64 | } 65 | 66 | /** 67 | * @return int[] 68 | */ 69 | public function getLineNumbers(): array { 70 | return array_map(function($message) { 71 | return $message->getLineNumber(); 72 | }, $this->messages); 73 | } 74 | 75 | /** 76 | * @return static 77 | */ 78 | public static function getNewMessages(string $unifiedDiff, self $unmodifiedMessages, self $modifiedMessages) { 79 | $map = DiffLineMap::fromUnifiedDiff($unifiedDiff); 80 | $fileName = DiffLineMap::getFileNameFromDiff($unifiedDiff); 81 | $newMessages = self::fromLintMessages(array_values(array_filter($modifiedMessages->getMessages(), function($newMessage) use ($unmodifiedMessages, $map) { 82 | $lineNumber = $newMessage->getLineNumber(); 83 | if (! $lineNumber) { 84 | return true; 85 | } 86 | $unmodifiedLineNumber = $map->getOldLineNumberForLine($lineNumber); 87 | $unmodifiedMessagesContainingUnmodifiedLineNumber = array_values(array_filter($unmodifiedMessages->getMessages(), function($unmodifiedMessage) use ($unmodifiedLineNumber) { 88 | return $unmodifiedMessage->getLineNumber() === $unmodifiedLineNumber; 89 | })); 90 | return ! (count($unmodifiedMessagesContainingUnmodifiedLineNumber) > 0); 91 | })), $fileName); 92 | 93 | // Preserve timing data from the modified messages 94 | $newMessages->setAllTiming($modifiedMessages->getAllTiming()); 95 | 96 | return $newMessages; 97 | } 98 | 99 | /** 100 | * Set timing data for a file 101 | * 102 | * @param string $fileName The file name or path 103 | * @param float $duration Duration in seconds 104 | */ 105 | public function setTiming(string $fileName, float $duration): void { 106 | $this->timingData[$fileName] = $duration; 107 | } 108 | 109 | /** 110 | * Get timing data for a specific file 111 | * 112 | * @param string $fileName The file name or path 113 | * @return float Duration in seconds, or 0.0 if not set 114 | */ 115 | public function getTiming(string $fileName): float { 116 | return $this->timingData[$fileName] ?? 0.0; 117 | } 118 | 119 | /** 120 | * Get all timing data 121 | * 122 | * @return array Array mapping file names to durations in seconds 123 | */ 124 | public function getAllTiming(): array { 125 | return $this->timingData; 126 | } 127 | 128 | /** 129 | * Set all timing data at once 130 | * 131 | * @param array $timingData Array mapping file names to durations 132 | */ 133 | public function setAllTiming(array $timingData): void { 134 | $this->timingData = $timingData; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /bin/phpcs-changed: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | isFile() && !fileHasValidExtension($file, isset($options['extensions']) && is_string($options['extensions']) ? $options['extensions'] : '')) { 83 | return false; 84 | } 85 | return $iterator->hasChildren() || $file->isFile() ? true : false; 86 | })); 87 | foreach ($iterator as $file) { 88 | if (shouldIgnorePath($file->getPathName(), isset($options['ignore']) && is_string($options['ignore']) ? $options['ignore'] : null)) { 89 | continue; 90 | } 91 | $fileNamesExpanded[] = $file->getPathName(); 92 | } 93 | } elseif (!shouldIgnorePath($file, isset($options['ignore']) && is_string($options['ignore']) ? $options['ignore'] : null)) { 94 | $fileNamesExpanded[] = $file; 95 | } 96 | } 97 | 98 | if (isset($options['h']) || isset($options['help'])) { 99 | printHelp(); 100 | exit(0); 101 | } 102 | 103 | if (isset($options['version'])) { 104 | printVersion(); 105 | } 106 | 107 | if (isset($options['i'])) { 108 | $cliOptions = CliOptions::fromArray($options); 109 | $shell = new UnixShell($cliOptions); 110 | printInstalledCodingStandards($shell); 111 | } 112 | 113 | // --git-branch exists for compatibility, --git-base supports branches 114 | if (isset($options['git-branch'])) { 115 | $options['git-base'] = $options['git-branch']; 116 | unset($options['git-branch']); 117 | } 118 | 119 | // --arc-lint is a shorthand for several other options 120 | if (isset($options['arc-lint'])) { 121 | $options['no-verify-git-file'] = 1; 122 | $options['always-exit-zero'] = 1; 123 | unset($options['arc-lint']); 124 | } 125 | 126 | if (isset($options['phpcs-orig'])) { 127 | $options['phpcs-unmodified'] = $options['phpcs-orig']; 128 | unset($options['phpcs-orig']); 129 | } 130 | if (isset($options['phpcs-new'])) { 131 | $options['phpcs-modified'] = $options['phpcs-new']; 132 | unset($options['phpcs-new']); 133 | } 134 | 135 | $options['files'] = $fileNamesExpanded; 136 | 137 | $debug = getDebug(isset($options['debug'])); 138 | run($options, $debug); 139 | 140 | function run(array $rawOptions, callable $debug): void { 141 | $encodedOptions = json_encode($rawOptions); 142 | if ($encodedOptions !== false) { 143 | $debug('Options: ' . $encodedOptions); 144 | } 145 | try { 146 | $options = CliOptions::fromArray($rawOptions); 147 | } catch ( InvalidOptionException $err ) { 148 | printErrorAndExit($err->getMessage()); 149 | exit(1); 150 | } 151 | $debug('Running on filenames: ' . implode(', ', $options->files)); 152 | 153 | if ($options->mode === Modes::MANUAL) { 154 | $shell = new UnixShell($options); 155 | reportMessagesAndExit( 156 | runManualWorkflow($options->diffFile, $options->phpcsUnmodified, $options->phpcsModified), 157 | $options, 158 | $shell 159 | ); 160 | return; 161 | } 162 | 163 | if ($options->mode === Modes::SVN) { 164 | $shell = new UnixShell($options); 165 | reportMessagesAndExit( 166 | runSvnWorkflow($options->files, $options, $shell, new CacheManager(new FileCache(), $debug), $debug), 167 | $options, 168 | $shell 169 | ); 170 | return; 171 | } 172 | 173 | if ($options->isGitMode()) { 174 | $shell = new UnixShell($options); 175 | reportMessagesAndExit( 176 | runGitWorkflow($options, $shell, new CacheManager(new FileCache(), $debug), $debug), 177 | $options, 178 | $shell 179 | ); 180 | return; 181 | } 182 | 183 | printHelp(); 184 | exit(1); 185 | } 186 | 187 | -------------------------------------------------------------------------------- /tests/DiffLineMapTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('bin/review-stuck-orders.php', $name); 27 | } 28 | 29 | public function testGetLineNumberForSimpleAdd() { 30 | $diff = <<assertNull($map->getOldLineNumberForLine(20)); 46 | $this->assertEquals(17, $map->getOldLineNumberForLine(17)); 47 | $this->assertEquals(20, $map->getOldLineNumberForLine(21)); 48 | $this->assertEquals(21, $map->getOldLineNumberForLine(22)); 49 | } 50 | 51 | public function testGetLineNumberForAllRemovalDiff() { 52 | $diff = <<assertEquals(1, $map->getOldLineNumberForLine(1)); 82 | $this->assertEquals(3, $map->getOldLineNumberForLine(2)); 83 | $this->assertEquals(4, $map->getOldLineNumberForLine(3)); 84 | $this->assertEquals(7, $map->getOldLineNumberForLine(6)); 85 | $this->assertEquals(12, $map->getOldLineNumberForLine(9)); 86 | $this->assertEquals(14, $map->getOldLineNumberForLine(11)); 87 | $this->assertEquals(22, $map->getOldLineNumberForLine(17)); 88 | } 89 | 90 | public function testGetLineNumberForMixedDiff() { 91 | $diff = <<assertEquals(3, $map->getOldLineNumberForLine(1)); 114 | $this->assertNull($map->getOldLineNumberForLine(2)); 115 | $this->assertNull($map->getOldLineNumberForLine(3)); 116 | $this->assertEquals(5, $map->getOldLineNumberForLine(4)); 117 | $this->assertEquals(6, $map->getOldLineNumberForLine(5)); 118 | $this->assertEquals(9, $map->getOldLineNumberForLine(8)); 119 | $this->assertEquals(10, $map->getOldLineNumberForLine(9)); 120 | $this->assertNull($map->getOldLineNumberForLine(11)); 121 | $this->assertNull($map->getOldLineNumberForLine(12)); 122 | $this->assertNull($map->getOldLineNumberForLine(13)); 123 | } 124 | 125 | public function testGetLineNumberForDiffWithOriginalAndNewErrorsOnSameLines() { 126 | $diff = <<assertNull($map->getOldLineNumberForLine(20)); 143 | } 144 | 145 | public function testGetLineNumberOutsideOfHunks() { 146 | $diff = <<assertEquals(8, $map->getOldLineNumberForLine(7)); 169 | $this->assertEquals(12, $map->getOldLineNumberForLine(14)); 170 | $this->assertEquals(13, $map->getOldLineNumberForLine(15)); 171 | $this->assertEquals(15, $map->getOldLineNumberForLine(17)); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /PhpcsChanged/CacheManager.php: -------------------------------------------------------------------------------- 1 | >>> 25 | */ 26 | private $fileDataByPath = []; 27 | 28 | /** 29 | * @var bool 30 | */ 31 | private $hasBeenModified = false; 32 | 33 | /** 34 | * @var CacheInterface 35 | */ 36 | private $cache; 37 | 38 | /** 39 | * @var CacheObject 40 | */ 41 | private $cacheObject; 42 | 43 | /** 44 | * @var callable 45 | */ 46 | private $debug; 47 | 48 | public function __construct(CacheInterface $cache, ?callable $debug = null) { 49 | $this->cache = $cache; 50 | $noopDebug = 51 | /** @param string[] $output */ 52 | /** @psalm-suppress UnusedClosureParam, MissingClosureParamType */ 53 | function(...$output): void {}; // phpcs:ignore VariableAnalysis 54 | $this->debug = $debug ?? $noopDebug; 55 | } 56 | 57 | public function load(): void { 58 | ($this->debug)("Loading cache..."); 59 | $this->cacheObject = $this->cache->load(); 60 | 61 | // Don't try to use old cache versions 62 | $version = getVersion(); 63 | if (! $this->cacheObject->cacheVersion) { 64 | $this->cacheObject->cacheVersion = $version; 65 | } 66 | if ($this->cacheObject->cacheVersion !== $version) { 67 | ($this->debug)("Cache version has changed ({$this->cacheObject->cacheVersion} -> {$version}). Clearing cache."); 68 | $this->clearCache(); 69 | $this->cacheObject->cacheVersion = $version; 70 | } 71 | 72 | // Keep a map of cache data so it's faster to access 73 | foreach($this->cacheObject->entries as $entry) { 74 | $this->addCacheEntry($entry); 75 | } 76 | 77 | $this->hasBeenModified = false; 78 | ($this->debug)("Cache loaded."); 79 | } 80 | 81 | public function save(): void { 82 | if (! $this->hasBeenModified) { 83 | ($this->debug)("Not saving cache. It is unchanged."); 84 | return; 85 | } 86 | ($this->debug)("Saving cache."); 87 | 88 | // Copy cache data map back to object 89 | $this->cacheObject->entries = $this->getEntries(); 90 | 91 | $this->cache->save($this->cacheObject); 92 | $this->hasBeenModified = false; 93 | } 94 | 95 | public function getCacheVersion(): string { 96 | return $this->cacheObject->cacheVersion; 97 | } 98 | 99 | /** 100 | * @return CacheEntry[] 101 | */ 102 | public function getEntries(): array { 103 | return $this->flattenArray($this->fileDataByPath); 104 | } 105 | 106 | /** 107 | * Flatten an array 108 | * 109 | * From https://stackoverflow.com/questions/1319903/how-to-flatten-a-multidimensional-array 110 | * 111 | * @param array|CacheEntry $array 112 | */ 113 | private function flattenArray($array): array { 114 | if (!is_array($array)) { 115 | // nothing to do if it's not an array 116 | return array($array); 117 | } 118 | 119 | $result = array(); 120 | foreach ($array as $value) { 121 | // explode the sub-array, and add the parts 122 | $result = array_merge($result, $this->flattenArray($value)); 123 | } 124 | 125 | return $result; 126 | } 127 | 128 | public function setCacheVersion(string $cacheVersion): void { 129 | if ($this->cacheObject->cacheVersion === $cacheVersion) { 130 | return; 131 | } 132 | ($this->debug)("Cache version has changed ('{$this->cacheObject->cacheVersion}' -> '{$cacheVersion}'). Clearing cache."); 133 | $this->hasBeenModified = true; 134 | $this->clearCache(); 135 | $this->cacheObject->cacheVersion = $cacheVersion; 136 | } 137 | 138 | public function getCacheForFile(string $filePath, string $type, string $hash, string $phpcsStandard, string $warningSeverity, string $errorSeverity): ?string { 139 | $phpcsStandard = $this->getPhpcsStandardCacheKey( $phpcsStandard, $warningSeverity, $errorSeverity ); 140 | 141 | $entry = $this->fileDataByPath[$filePath][$type][$hash][$phpcsStandard] ?? null; 142 | if (! $entry) { 143 | ($this->debug)("Cache miss: file '{$filePath}', hash '{$hash}', standard '{$phpcsStandard}'"); 144 | return null; 145 | } 146 | return $entry->data; 147 | } 148 | 149 | public function setCacheForFile(string $filePath, string $type, string $hash, string $phpcsStandard, string $warningSeverity, string $errorSeverity, string $data): void { 150 | 151 | $phpcsStandard = $this->getPhpcsStandardCacheKey( $phpcsStandard, $warningSeverity, $errorSeverity ); 152 | 153 | $this->hasBeenModified = true; 154 | $entry = new CacheEntry(); 155 | $entry->phpcsStandard = $phpcsStandard; 156 | $entry->hash = $hash; 157 | $entry->data = $data; 158 | $entry->path = $filePath; 159 | $entry->type = $type; 160 | $this->addCacheEntry($entry); 161 | } 162 | 163 | public function addCacheEntry(CacheEntry $entry): void { 164 | $this->hasBeenModified = true; 165 | $this->pruneOldEntriesForFile($entry); 166 | if (! array_key_exists($entry->path, $this->fileDataByPath)) { 167 | $this->fileDataByPath[$entry->path] = []; 168 | } 169 | if (! array_key_exists($entry->type, $this->fileDataByPath[$entry->path])) { 170 | $this->fileDataByPath[$entry->path][$entry->type] = []; 171 | } 172 | if (! array_key_exists($entry->hash, $this->fileDataByPath[$entry->path][$entry->type])) { 173 | $this->fileDataByPath[$entry->path][$entry->type][$entry->hash] = []; 174 | } 175 | $this->fileDataByPath[$entry->path][$entry->type][$entry->hash][$entry->phpcsStandard] = $entry; 176 | ($this->debug)("Cache add: file '{$entry->path}', type '{$entry->type}', hash '{$entry->hash}', standard '{$entry->phpcsStandard}'"); 177 | } 178 | 179 | public function getPhpcsStandardCacheKey( string $phpcsStandard, string $warningSeverity, string $errorSeverity ): string { 180 | $warningSeverity = '' === $warningSeverity ? self::DEFAULT_SEVERITY : $warningSeverity; 181 | $errorSeverity = '' === $errorSeverity ? self::DEFAULT_SEVERITY : $errorSeverity; 182 | if (self::DEFAULT_SEVERITY !== $warningSeverity || self::DEFAULT_SEVERITY !==$errorSeverity) { 183 | $phpcsStandard .= ':w' . $warningSeverity . 'e' . $errorSeverity; 184 | } 185 | return $phpcsStandard; 186 | } 187 | 188 | private function pruneOldEntriesForFile(CacheEntry $newEntry): void { 189 | foreach ($this->getEntries() as $oldEntry) { 190 | if ($this->shouldEntryBeRemoved($oldEntry, $newEntry)) { 191 | $this->removeCacheEntry($oldEntry); 192 | } 193 | } 194 | } 195 | 196 | private function shouldEntryBeRemoved(CacheEntry $oldEntry, CacheEntry $newEntry): bool { 197 | if ($oldEntry->path === $newEntry->path && $oldEntry->type === $newEntry->type && $oldEntry->phpcsStandard === $newEntry->phpcsStandard) { 198 | return true; 199 | } 200 | return false; 201 | } 202 | 203 | public function removeCacheEntry(CacheEntry $entry): void { 204 | if (isset($this->fileDataByPath[$entry->path][$entry->type][$entry->hash][$entry->phpcsStandard])) { 205 | ($this->debug)("Cache remove: file '{$entry->path}', type '{$entry->type}', hash '{$entry->hash}', standard '{$entry->phpcsStandard}'"); 206 | unset($this->fileDataByPath[$entry->path][$entry->type][$entry->hash][$entry->phpcsStandard]); 207 | } 208 | } 209 | 210 | public function clearCache(): void { 211 | ($this->debug)("Cache cleared"); 212 | $this->hasBeenModified = true; 213 | $this->fileDataByPath = []; 214 | $this->cacheObject->entries = []; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /tests/fixtures/old-phpcs-output.json: -------------------------------------------------------------------------------- 1 | {"totals":{"errors":0,"warnings":56,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":56,"messages":[{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Import","severity":5,"type":"WARNING","line":20,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":99,"column":16,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":108,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":111,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":114,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":127,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":135,"column":19,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":232,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":257,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":267,"column":54,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":268,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":278,"column":53,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":279,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":299,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":304,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":314,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":321,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":328,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":329,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":330,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":336,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":344,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":351,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":352,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":358,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":365,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":373,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":381,"column":9,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":384,"column":40,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":411,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":416,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":422,"column":24,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":441,"column":37,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":445,"column":37,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":449,"column":15,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":450,"column":13,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":457,"column":21,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":460,"column":9,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":463,"column":49,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":466,"column":12,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":469,"column":41,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":472,"column":41,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":486,"column":58,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":487,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":530,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":595,"column":61,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":643,"column":58,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":657,"column":8,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":664,"column":17,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":665,"column":13,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":684,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":690,"column":66,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":691,"column":36,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":711,"column":12,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":716,"column":19,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":766,"column":1,"fixable":false}]}}} 2 | -------------------------------------------------------------------------------- /tests/fixtures/new-phpcs-output.json: -------------------------------------------------------------------------------- 1 | {"totals":{"errors":0,"warnings":57,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":57,"messages":[{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Import","severity":5,"type":"WARNING","line":20,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Import","severity":5,"type":"WARNING","line":21,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":100,"column":16,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":109,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":112,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":115,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":128,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":136,"column":19,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":233,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":258,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":268,"column":54,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":269,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":279,"column":53,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":280,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":300,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":305,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":315,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":322,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":329,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":330,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":331,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":337,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":345,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":352,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":353,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":359,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":366,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":374,"column":4,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":382,"column":9,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":385,"column":40,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":412,"column":5,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":417,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":423,"column":24,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":442,"column":37,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":446,"column":37,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":450,"column":15,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":451,"column":13,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":458,"column":21,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":461,"column":9,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":464,"column":49,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":467,"column":12,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":470,"column":41,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":473,"column":41,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":487,"column":58,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":488,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":531,"column":6,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":596,"column":61,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":644,"column":58,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":658,"column":8,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":665,"column":17,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":666,"column":13,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":685,"column":3,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":691,"column":66,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":692,"column":36,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":712,"column":12,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":717,"column":19,"fixable":false},{"message":"Found problem.","source":"ImportDetection.Imports.RequireImports.Symbol","severity":5,"type":"WARNING","line":767,"column":1,"fixable":false}]}}} 2 | -------------------------------------------------------------------------------- /tests/CheckstyleReporterTest.php: -------------------------------------------------------------------------------- 1 | 'WARNING', 16 | 'severity' => 5, 17 | 'fixable' => false, 18 | 'column' => 5, 19 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 20 | 'line' => 15, 21 | 'message' => 'Found unused symbol Foo.', 22 | ], 23 | ], 'fileA.php'); 24 | $expected = << 26 | 27 | 28 | 29 | 30 | 31 | 32 | EOF; 33 | $reporter = new CheckstyleReporter(); 34 | $result = $reporter->getFormattedMessages($messages, []); 35 | $this->assertEquals($expected, $result); 36 | } 37 | 38 | public function testSingleError() { 39 | $messages = PhpcsMessages::fromArrays([ 40 | [ 41 | 'type' => 'ERROR', 42 | 'severity' => 5, 43 | 'fixable' => false, 44 | 'column' => 5, 45 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 46 | 'line' => 15, 47 | 'message' => 'Found unused symbol Foo.', 48 | ], 49 | ], 'fileA.php'); 50 | $expected = << 52 | 53 | 54 | 55 | 56 | 57 | 58 | EOF; 59 | $reporter = new CheckstyleReporter(); 60 | $result = $reporter->getFormattedMessages($messages, []); 61 | $this->assertEquals($expected, $result); 62 | } 63 | 64 | public function testMultipleWarningsWithLongLineNumber() { 65 | $messages = PhpcsMessages::fromArrays([ 66 | [ 67 | 'type' => 'WARNING', 68 | 'severity' => 5, 69 | 'fixable' => false, 70 | 'column' => 5, 71 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 72 | 'line' => 133825, 73 | 'message' => 'Found unused symbol Foo.', 74 | ], 75 | [ 76 | 'type' => 'WARNING', 77 | 'severity' => 5, 78 | 'fixable' => false, 79 | 'column' => 5, 80 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 81 | 'line' => 15, 82 | 'message' => 'Found unused symbol Bar.', 83 | ], 84 | ], 'fileA.php'); 85 | $expected = << 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | EOF; 95 | $reporter = new CheckstyleReporter(); 96 | $result = $reporter->getFormattedMessages($messages, []); 97 | $this->assertEquals($expected, $result); 98 | } 99 | 100 | public function testMultipleWarningsErrorsAndFiles() { 101 | $messagesA = PhpcsMessages::fromArrays([ 102 | [ 103 | 'type' => 'ERROR', 104 | 'severity' => 5, 105 | 'fixable' => true, 106 | 'column' => 2, 107 | 'source' => 'ImportDetection.Imports.RequireImports.Something', 108 | 'line' => 12, 109 | 'message' => 'Found unused symbol Faa.', 110 | ], 111 | [ 112 | 'type' => 'ERROR', 113 | 'severity' => 5, 114 | 'fixable' => false, 115 | 'column' => 5, 116 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 117 | 'line' => 15, 118 | 'message' => 'Found unused symbol Foo.', 119 | ], 120 | [ 121 | 'type' => 'WARNING', 122 | 'severity' => 5, 123 | 'fixable' => false, 124 | 'column' => 8, 125 | 'source' => 'ImportDetection.Imports.RequireImports.Boom', 126 | 'line' => 18, 127 | 'message' => 'Found unused symbol Bar.', 128 | ], 129 | ], 'fileA.php'); 130 | $messagesB = PhpcsMessages::fromArrays([ 131 | [ 132 | 'type' => 'WARNING', 133 | 'severity' => 5, 134 | 'fixable' => false, 135 | 'column' => 5, 136 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop', 137 | 'line' => 30, 138 | 'message' => 'Found unused symbol Hi.', 139 | ], 140 | ], 'fileB.php'); 141 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]); 142 | $expected = << 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | EOF; 156 | $reporter = new CheckstyleReporter(); 157 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]); 158 | $this->assertEquals($expected, $result); 159 | } 160 | 161 | public function testNoWarnings() { 162 | $messages = PhpcsMessages::fromArrays([]); 163 | $expected = << 165 | 166 | 167 | 168 | EOF; 169 | $reporter = new CheckstyleReporter(); 170 | $result = $reporter->getFormattedMessages($messages, []); 171 | $this->assertEquals($expected, $result); 172 | } 173 | 174 | public function testSingleWarningWithNoFilename() { 175 | $messages = PhpcsMessages::fromArrays([ 176 | [ 177 | 'type' => 'WARNING', 178 | 'severity' => 5, 179 | 'fixable' => false, 180 | 'column' => 5, 181 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 182 | 'line' => 15, 183 | 'message' => 'Found unused symbol Foo.', 184 | ], 185 | ]); 186 | $expected = << 188 | 189 | 190 | 191 | 192 | 193 | 194 | EOF; 195 | $reporter = new CheckstyleReporter(); 196 | $result = $reporter->getFormattedMessages($messages, []); 197 | $this->assertEquals($expected, $result); 198 | } 199 | 200 | public function testXmlEscaping() { 201 | $messages = PhpcsMessages::fromArrays([ 202 | [ 203 | 'type' => 'ERROR', 204 | 'severity' => 5, 205 | 'fixable' => false, 206 | 'column' => 5, 207 | 'source' => 'Test.Source<>&"', 208 | 'line' => 15, 209 | 'message' => 'Message with & "quotes".', 210 | ], 211 | ], 'fileA.php'); 212 | $expected = << 214 | 215 | 216 | 217 | 218 | 219 | 220 | EOF; 221 | $reporter = new CheckstyleReporter(); 222 | $result = $reporter->getFormattedMessages($messages, []); 223 | $this->assertEquals($expected, $result); 224 | } 225 | 226 | public function testGetExitCodeWithMessages() { 227 | $messages = PhpcsMessages::fromArrays([ 228 | [ 229 | 'type' => 'WARNING', 230 | 'severity' => 5, 231 | 'fixable' => false, 232 | 'column' => 5, 233 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 234 | 'line' => 15, 235 | 'message' => 'Found unused symbol Foo.', 236 | ], 237 | ], 'fileA.php'); 238 | $reporter = new CheckstyleReporter(); 239 | $this->assertEquals(1, $reporter->getExitCode($messages)); 240 | } 241 | 242 | public function testGetExitCodeWithNoMessages() { 243 | $messages = PhpcsMessages::fromArrays([], 'fileA.php'); 244 | $reporter = new CheckstyleReporter(); 245 | $this->assertEquals(0, $reporter->getExitCode($messages)); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /tests/JsonReporterTest.php: -------------------------------------------------------------------------------- 1 | 'WARNING', 15 | 'severity' => 5, 16 | 'fixable' => false, 17 | 'column' => 5, 18 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 19 | 'line' => 15, 20 | 'message' => 'Found unused symbol Foo.', 21 | ], 22 | ], 'fileA.php'); 23 | $expected = <<getFormattedMessages($messages, []); 28 | $this->assertEquals($expected, $result); 29 | } 30 | 31 | public function testSingleWarningWithShowCodeOption() { 32 | $messages = PhpcsMessages::fromArrays([ 33 | [ 34 | 'type' => 'WARNING', 35 | 'severity' => 5, 36 | 'fixable' => false, 37 | 'column' => 5, 38 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 39 | 'line' => 15, 40 | 'message' => 'Found unused symbol Foo.', 41 | ], 42 | ], 'fileA.php'); 43 | $expected = <<getFormattedMessages($messages, ['s' => 1]); 48 | $this->assertEquals($expected, $result); 49 | } 50 | 51 | public function testSingleWarningWithShowCodeOptionAndNoCode() { 52 | $messages = PhpcsMessages::fromArrays([ 53 | [ 54 | 'type' => 'WARNING', 55 | 'severity' => 5, 56 | 'fixable' => false, 57 | 'column' => 5, 58 | 'line' => 15, 59 | 'message' => 'Found unused symbol Foo.', 60 | ], 61 | ], 'fileA.php'); 62 | $expected = <<getFormattedMessages($messages, ['s' => 1]); 67 | $this->assertEquals($expected, $result); 68 | } 69 | 70 | public function testMultipleWarningsWithLongLineNumber() { 71 | $messages = PhpcsMessages::fromArrays([ 72 | [ 73 | 'type' => 'WARNING', 74 | 'severity' => 5, 75 | 'fixable' => false, 76 | 'column' => 5, 77 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 78 | 'line' => 133825, 79 | 'message' => 'Found unused symbol Foo.', 80 | ], 81 | [ 82 | 'type' => 'WARNING', 83 | 'severity' => 5, 84 | 'fixable' => false, 85 | 'column' => 5, 86 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 87 | 'line' => 15, 88 | 'message' => 'Found unused symbol Bar.', 89 | ], 90 | ], 'fileA.php'); 91 | $expected = <<getFormattedMessages($messages, []); 96 | $this->assertEquals($expected, $result); 97 | } 98 | 99 | public function testMultipleWarningsErrorsAndFiles() { 100 | $messagesA = PhpcsMessages::fromArrays([ 101 | [ 102 | 'type' => 'ERROR', 103 | 'severity' => 5, 104 | 'fixable' => true, 105 | 'column' => 2, 106 | 'source' => 'ImportDetection.Imports.RequireImports.Something', 107 | 'line' => 12, 108 | 'message' => 'Found unused symbol Faa.', 109 | ], 110 | [ 111 | 'type' => 'ERROR', 112 | 'severity' => 5, 113 | 'fixable' => false, 114 | 'column' => 5, 115 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 116 | 'line' => 15, 117 | 'message' => 'Found unused symbol Foo.', 118 | ], 119 | [ 120 | 'type' => 'WARNING', 121 | 'severity' => 5, 122 | 'fixable' => false, 123 | 'column' => 8, 124 | 'source' => 'ImportDetection.Imports.RequireImports.Boom', 125 | 'line' => 18, 126 | 'message' => 'Found unused symbol Bar.', 127 | ], 128 | [ 129 | 'type' => 'WARNING', 130 | 'severity' => 5, 131 | 'fixable' => false, 132 | 'column' => 5, 133 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 134 | 'line' => 22, 135 | 'message' => 'Found unused symbol Foo.', 136 | ], 137 | ], 'fileA.php'); 138 | $messagesB = PhpcsMessages::fromArrays([ 139 | [ 140 | 'type' => 'WARNING', 141 | 'severity' => 5, 142 | 'fixable' => false, 143 | 'column' => 5, 144 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop', 145 | 'line' => 30, 146 | 'message' => 'Found unused symbol Hi.', 147 | ], 148 | ], 'fileB.php'); 149 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]); 150 | $expected = <<getFormattedMessages($messages, ['s' => 1]); 155 | $this->assertEquals($expected, $result); 156 | } 157 | 158 | public function testNoWarnings() { 159 | $messages = PhpcsMessages::fromArrays([]); 160 | $expected = <<getFormattedMessages($messages, []); 165 | $this->assertEquals($expected, $result); 166 | } 167 | 168 | public function testSingleWarningWithNoFilename() { 169 | $messages = PhpcsMessages::fromArrays([ 170 | [ 171 | 'type' => 'WARNING', 172 | 'severity' => 5, 173 | 'fixable' => false, 174 | 'column' => 5, 175 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 176 | 'line' => 15, 177 | 'message' => 'Found unused symbol Foo.', 178 | ], 179 | ]); 180 | $expected = <<getFormattedMessages($messages, []); 185 | $this->assertEquals($expected, $result); 186 | } 187 | 188 | public function testGetExitCodeWithMessages() { 189 | $messages = PhpcsMessages::fromArrays([ 190 | [ 191 | 'type' => 'WARNING', 192 | 'severity' => 5, 193 | 'fixable' => false, 194 | 'column' => 5, 195 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 196 | 'line' => 15, 197 | 'message' => 'Found unused symbol Foo.', 198 | ], 199 | ], 'fileA.php'); 200 | $reporter = new JsonReporter(); 201 | $this->assertEquals(1, $reporter->getExitCode($messages)); 202 | } 203 | 204 | public function testGetExitCodeWithNoMessages() { 205 | $messages = PhpcsMessages::fromArrays([], 'fileA.php'); 206 | $reporter = new JsonReporter(); 207 | $this->assertEquals(0, $reporter->getExitCode($messages)); 208 | } 209 | } 210 | 211 | -------------------------------------------------------------------------------- /tests/FullReporterTest.php: -------------------------------------------------------------------------------- 1 | 'WARNING', 15 | 'severity' => 5, 16 | 'fixable' => false, 17 | 'column' => 5, 18 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 19 | 'line' => 15, 20 | 'message' => 'Found unused symbol Foo.', 21 | ], 22 | ], 'fileA.php'); 23 | $expected = <<getFormattedMessages($messages, []); 35 | $this->assertEquals($expected, $result); 36 | } 37 | 38 | public function testSingleWarningWithShowCodeOption() { 39 | $messages = PhpcsMessages::fromArrays([ 40 | [ 41 | 'type' => 'WARNING', 42 | 'severity' => 5, 43 | 'fixable' => false, 44 | 'column' => 5, 45 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 46 | 'line' => 15, 47 | 'message' => 'Found unused symbol Foo.', 48 | ], 49 | ], 'fileA.php'); 50 | $expected = <<getFormattedMessages($messages, ['s' => 1]); 62 | $this->assertEquals($expected, $result); 63 | } 64 | 65 | public function testSingleWarningWithShowCodeOptionAndNoCode() { 66 | $messages = PhpcsMessages::fromArrays([ 67 | [ 68 | 'type' => 'WARNING', 69 | 'severity' => 5, 70 | 'fixable' => false, 71 | 'column' => 5, 72 | 'line' => 15, 73 | 'message' => 'Found unused symbol Foo.', 74 | ], 75 | ], 'fileA.php'); 76 | $expected = <<getFormattedMessages($messages, ['s' => 1]); 88 | $this->assertEquals($expected, $result); 89 | } 90 | 91 | public function testMultipleWarningsWithLongLineNumber() { 92 | $messages = PhpcsMessages::fromArrays([ 93 | [ 94 | 'type' => 'WARNING', 95 | 'severity' => 5, 96 | 'fixable' => false, 97 | 'column' => 5, 98 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 99 | 'line' => 133825, 100 | 'message' => 'Found unused symbol Foo.', 101 | ], 102 | [ 103 | 'type' => 'WARNING', 104 | 'severity' => 5, 105 | 'fixable' => false, 106 | 'column' => 5, 107 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 108 | 'line' => 15, 109 | 'message' => 'Found unused symbol Bar.', 110 | ], 111 | ], 'fileA.php'); 112 | $expected = <<getFormattedMessages($messages, []); 125 | $this->assertEquals($expected, $result); 126 | } 127 | 128 | public function testMultipleWarningsErrorsAndFiles() { 129 | $messagesA = PhpcsMessages::fromArrays([ 130 | [ 131 | 'type' => 'ERROR', 132 | 'severity' => 5, 133 | 'fixable' => true, 134 | 'column' => 2, 135 | 'source' => 'ImportDetection.Imports.RequireImports.Something', 136 | 'line' => 12, 137 | 'message' => 'Found unused symbol Faa.', 138 | ], 139 | [ 140 | 'type' => 'ERROR', 141 | 'severity' => 5, 142 | 'fixable' => false, 143 | 'column' => 5, 144 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 145 | 'line' => 15, 146 | 'message' => 'Found unused symbol Foo.', 147 | ], 148 | [ 149 | 'type' => 'WARNING', 150 | 'severity' => 5, 151 | 'fixable' => false, 152 | 'column' => 8, 153 | 'source' => 'ImportDetection.Imports.RequireImports.Boom', 154 | 'line' => 18, 155 | 'message' => 'Found unused symbol Bar.', 156 | ], 157 | [ 158 | 'type' => 'WARNING', 159 | 'severity' => 5, 160 | 'fixable' => false, 161 | 'column' => 5, 162 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 163 | 'line' => 22, 164 | 'message' => 'Found unused symbol Foo.', 165 | ], 166 | ], 'fileA.php'); 167 | $messagesB = PhpcsMessages::fromArrays([ 168 | [ 169 | 'type' => 'WARNING', 170 | 'severity' => 5, 171 | 'fixable' => false, 172 | 'column' => 5, 173 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop', 174 | 'line' => 30, 175 | 'message' => 'Found unused symbol Hi.', 176 | ], 177 | ], 'fileB.php'); 178 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]); 179 | $expected = <<getFormattedMessages($messages, ['s' => 1]); 202 | $this->assertEquals($expected, $result); 203 | } 204 | 205 | public function testNoWarnings() { 206 | $messages = PhpcsMessages::fromArrays([]); 207 | $expected = <<getFormattedMessages($messages, []); 212 | $this->assertEquals($expected, $result); 213 | } 214 | 215 | public function testSingleWarningWithNoFilename() { 216 | $messages = PhpcsMessages::fromArrays([ 217 | [ 218 | 'type' => 'WARNING', 219 | 'severity' => 5, 220 | 'fixable' => false, 221 | 'column' => 5, 222 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 223 | 'line' => 15, 224 | 'message' => 'Found unused symbol Foo.', 225 | ], 226 | ]); 227 | $expected = <<getFormattedMessages($messages, []); 239 | $this->assertEquals($expected, $result); 240 | } 241 | 242 | public function testGetExitCodeWithMessages() { 243 | $messages = PhpcsMessages::fromArrays([ 244 | [ 245 | 'type' => 'WARNING', 246 | 'severity' => 5, 247 | 'fixable' => false, 248 | 'column' => 5, 249 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 250 | 'line' => 15, 251 | 'message' => 'Found unused symbol Foo.', 252 | ], 253 | ], 'fileA.php'); 254 | $reporter = new FullReporter(); 255 | $this->assertEquals(1, $reporter->getExitCode($messages)); 256 | } 257 | 258 | public function testGetExitCodeWithNoMessages() { 259 | $messages = PhpcsMessages::fromArrays([], 'fileA.php'); 260 | $reporter = new FullReporter(); 261 | $this->assertEquals(0, $reporter->getExitCode($messages)); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /tests/JunitReporterTest.php: -------------------------------------------------------------------------------- 1 | 'WARNING', 16 | 'severity' => 5, 17 | 'fixable' => false, 18 | 'column' => 5, 19 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 20 | 'line' => 15, 21 | 'message' => 'Found unused symbol Foo.', 22 | ], 23 | ], 'fileA.php'); 24 | $expected = << 26 | 27 | 28 | 29 | Line 15, Column 5: Found unused symbol Foo. (Severity: 5) 30 | 31 | 32 | 33 | 34 | EOF; 35 | $reporter = new JunitReporter(); 36 | $result = $reporter->getFormattedMessages($messages, []); 37 | $this->assertEquals($expected, $result); 38 | } 39 | 40 | public function testSingleError() { 41 | $messages = PhpcsMessages::fromArrays([ 42 | [ 43 | 'type' => 'ERROR', 44 | 'severity' => 5, 45 | 'fixable' => false, 46 | 'column' => 5, 47 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 48 | 'line' => 15, 49 | 'message' => 'Found unused symbol Foo.', 50 | ], 51 | ], 'fileA.php'); 52 | $expected = << 54 | 55 | 56 | 57 | Line 15, Column 5: Found unused symbol Foo. (Severity: 5) 58 | 59 | 60 | 61 | 62 | EOF; 63 | $reporter = new JunitReporter(); 64 | $result = $reporter->getFormattedMessages($messages, []); 65 | $this->assertEquals($expected, $result); 66 | } 67 | 68 | public function testMultipleWarningsWithLongLineNumber() { 69 | $messages = PhpcsMessages::fromArrays([ 70 | [ 71 | 'type' => 'WARNING', 72 | 'severity' => 5, 73 | 'fixable' => false, 74 | 'column' => 5, 75 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 76 | 'line' => 133825, 77 | 'message' => 'Found unused symbol Foo.', 78 | ], 79 | [ 80 | 'type' => 'WARNING', 81 | 'severity' => 5, 82 | 'fixable' => false, 83 | 'column' => 5, 84 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 85 | 'line' => 15, 86 | 'message' => 'Found unused symbol Bar.', 87 | ], 88 | ], 'fileA.php'); 89 | $expected = << 91 | 92 | 93 | 94 | Line 133825, Column 5: Found unused symbol Foo. (Severity: 5) 95 | 96 | 97 | Line 15, Column 5: Found unused symbol Bar. (Severity: 5) 98 | 99 | 100 | 101 | 102 | EOF; 103 | $reporter = new JunitReporter(); 104 | $result = $reporter->getFormattedMessages($messages, []); 105 | $this->assertEquals($expected, $result); 106 | } 107 | 108 | public function testMultipleWarningsErrorsAndFiles() { 109 | $messagesA = PhpcsMessages::fromArrays([ 110 | [ 111 | 'type' => 'ERROR', 112 | 'severity' => 5, 113 | 'fixable' => true, 114 | 'column' => 2, 115 | 'source' => 'ImportDetection.Imports.RequireImports.Something', 116 | 'line' => 12, 117 | 'message' => 'Found unused symbol Faa.', 118 | ], 119 | [ 120 | 'type' => 'ERROR', 121 | 'severity' => 5, 122 | 'fixable' => false, 123 | 'column' => 5, 124 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 125 | 'line' => 15, 126 | 'message' => 'Found unused symbol Foo.', 127 | ], 128 | [ 129 | 'type' => 'WARNING', 130 | 'severity' => 5, 131 | 'fixable' => false, 132 | 'column' => 8, 133 | 'source' => 'ImportDetection.Imports.RequireImports.Boom', 134 | 'line' => 18, 135 | 'message' => 'Found unused symbol Bar.', 136 | ], 137 | [ 138 | 'type' => 'WARNING', 139 | 'severity' => 5, 140 | 'fixable' => false, 141 | 'column' => 5, 142 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 143 | 'line' => 22, 144 | 'message' => 'Found unused symbol Foo.', 145 | ], 146 | ], 'fileA.php'); 147 | $messagesB = PhpcsMessages::fromArrays([ 148 | [ 149 | 'type' => 'WARNING', 150 | 'severity' => 5, 151 | 'fixable' => false, 152 | 'column' => 5, 153 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop', 154 | 'line' => 30, 155 | 'message' => 'Found unused symbol Hi.', 156 | ], 157 | ], 'fileB.php'); 158 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]); 159 | $expected = << 161 | 162 | 163 | 164 | Line 12, Column 2: Found unused symbol Faa. (Severity: 5) 165 | 166 | 167 | Line 15, Column 5: Found unused symbol Foo. (Severity: 5) 168 | 169 | 170 | Line 18, Column 8: Found unused symbol Bar. (Severity: 5) 171 | 172 | 173 | Line 22, Column 5: Found unused symbol Foo. (Severity: 5) 174 | 175 | 176 | 177 | 178 | Line 30, Column 5: Found unused symbol Hi. (Severity: 5) 179 | 180 | 181 | 182 | 183 | EOF; 184 | $reporter = new JunitReporter(); 185 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]); 186 | $this->assertEquals($expected, $result); 187 | } 188 | 189 | public function testNoWarnings() { 190 | $messages = PhpcsMessages::fromArrays([]); 191 | $expected = << 193 | 194 | 195 | 196 | 197 | 198 | EOF; 199 | $reporter = new JunitReporter(); 200 | $result = $reporter->getFormattedMessages($messages, []); 201 | $this->assertEquals($expected, $result); 202 | } 203 | 204 | public function testSingleWarningWithNoFilename() { 205 | $messages = PhpcsMessages::fromArrays([ 206 | [ 207 | 'type' => 'WARNING', 208 | 'severity' => 5, 209 | 'fixable' => false, 210 | 'column' => 5, 211 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 212 | 'line' => 15, 213 | 'message' => 'Found unused symbol Foo.', 214 | ], 215 | ]); 216 | $expected = << 218 | 219 | 220 | 221 | Line 15, Column 5: Found unused symbol Foo. (Severity: 5) 222 | 223 | 224 | 225 | 226 | EOF; 227 | $reporter = new JunitReporter(); 228 | $result = $reporter->getFormattedMessages($messages, []); 229 | $this->assertEquals($expected, $result); 230 | } 231 | 232 | public function testXmlEscaping() { 233 | $messages = PhpcsMessages::fromArrays([ 234 | [ 235 | 'type' => 'ERROR', 236 | 'severity' => 5, 237 | 'fixable' => false, 238 | 'column' => 5, 239 | 'source' => 'Test.Source<>&"', 240 | 'line' => 15, 241 | 'message' => 'Message with & "quotes".', 242 | ], 243 | ], 'fileA.php'); 244 | $expected = << 246 | 247 | 248 | 249 | Line 15, Column 5: Message with <xml> & "quotes". (Severity: 5) 250 | 251 | 252 | 253 | 254 | EOF; 255 | $reporter = new JunitReporter(); 256 | $result = $reporter->getFormattedMessages($messages, []); 257 | $this->assertEquals($expected, $result); 258 | } 259 | 260 | public function testGetExitCodeWithMessages() { 261 | $messages = PhpcsMessages::fromArrays([ 262 | [ 263 | 'type' => 'WARNING', 264 | 'severity' => 5, 265 | 'fixable' => false, 266 | 'column' => 5, 267 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 268 | 'line' => 15, 269 | 'message' => 'Found unused symbol Foo.', 270 | ], 271 | ], 'fileA.php'); 272 | $reporter = new JunitReporter(); 273 | $this->assertEquals(1, $reporter->getExitCode($messages)); 274 | } 275 | 276 | public function testGetExitCodeWithNoMessages() { 277 | $messages = PhpcsMessages::fromArrays([], 'fileA.php'); 278 | $reporter = new JunitReporter(); 279 | $this->assertEquals(0, $reporter->getExitCode($messages)); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /tests/XmlReporterTest.php: -------------------------------------------------------------------------------- 1 | 'WARNING', 18 | 'severity' => 5, 19 | 'fixable' => false, 20 | 'column' => 5, 21 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 22 | 'line' => 15, 23 | 'message' => 'Found unused symbol Foo.', 24 | ], 25 | ], 'fileA.php'); 26 | $expected = << 28 | 29 | 30 | Found unused symbol Foo. 31 | 32 | 33 | 34 | EOF; 35 | $options = new CliOptions(); 36 | $shell = new TestShell($options, []); 37 | $shell->registerExecutable('phpcs'); 38 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 39 | $reporter = new TestXmlReporter($options, $shell); 40 | $result = $reporter->getFormattedMessages($messages, []); 41 | $this->assertEquals($expected, $result); 42 | } 43 | 44 | public function testSingleWarningWithShowCodeOption() { 45 | $messages = PhpcsMessages::fromArrays([ 46 | [ 47 | 'type' => 'WARNING', 48 | 'severity' => 5, 49 | 'fixable' => false, 50 | 'column' => 5, 51 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 52 | 'line' => 15, 53 | 'message' => 'Found unused symbol Foo.', 54 | ], 55 | ], 'fileA.php'); 56 | $expected = << 58 | 59 | 60 | Found unused symbol Foo. 61 | 62 | 63 | 64 | EOF; 65 | $options = new CliOptions(); 66 | $shell = new TestShell($options, []); 67 | $shell->registerExecutable('phpcs'); 68 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 69 | $reporter = new TestXmlReporter($options, $shell); 70 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]); 71 | $this->assertEquals($expected, $result); 72 | } 73 | 74 | public function testSingleWarningWithShowCodeOptionAndNoCode() { 75 | $messages = PhpcsMessages::fromArrays([ 76 | [ 77 | 'type' => 'WARNING', 78 | 'severity' => 5, 79 | 'fixable' => false, 80 | 'column' => 5, 81 | 'line' => 15, 82 | 'message' => 'Found unused symbol Foo.', 83 | ], 84 | ], 'fileA.php'); 85 | $expected = << 87 | 88 | 89 | Found unused symbol Foo. 90 | 91 | 92 | 93 | EOF; 94 | $options = new CliOptions(); 95 | $shell = new TestShell($options, []); 96 | $shell->registerExecutable('phpcs'); 97 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 98 | $reporter = new TestXmlReporter($options, $shell); 99 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]); 100 | $this->assertEquals($expected, $result); 101 | } 102 | 103 | public function testMultipleWarningsWithLongLineNumber() { 104 | $messages = PhpcsMessages::fromArrays([ 105 | [ 106 | 'type' => 'WARNING', 107 | 'severity' => 5, 108 | 'fixable' => false, 109 | 'column' => 5, 110 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 111 | 'line' => 133825, 112 | 'message' => 'Found unused symbol Foo.', 113 | ], 114 | [ 115 | 'type' => 'WARNING', 116 | 'severity' => 5, 117 | 'fixable' => false, 118 | 'column' => 5, 119 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 120 | 'line' => 15, 121 | 'message' => 'Found unused symbol Bar.', 122 | ], 123 | ], 'fileA.php'); 124 | $expected = << 126 | 127 | 128 | Found unused symbol Foo. 129 | Found unused symbol Bar. 130 | 131 | 132 | 133 | EOF; 134 | $options = new CliOptions(); 135 | $shell = new TestShell($options, []); 136 | $shell->registerExecutable('phpcs'); 137 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 138 | $reporter = new TestXmlReporter($options, $shell); 139 | $result = $reporter->getFormattedMessages($messages, []); 140 | $this->assertEquals($expected, $result); 141 | } 142 | 143 | public function testMultipleWarningsErrorsAndFiles() { 144 | $messagesA = PhpcsMessages::fromArrays([ 145 | [ 146 | 'type' => 'ERROR', 147 | 'severity' => 5, 148 | 'fixable' => true, 149 | 'column' => 2, 150 | 'source' => 'ImportDetection.Imports.RequireImports.Something', 151 | 'line' => 12, 152 | 'message' => 'Found unused symbol Faa.', 153 | ], 154 | [ 155 | 'type' => 'ERROR', 156 | 'severity' => 5, 157 | 'fixable' => false, 158 | 'column' => 5, 159 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 160 | 'line' => 15, 161 | 'message' => 'Found unused symbol Foo.', 162 | ], 163 | [ 164 | 'type' => 'WARNING', 165 | 'severity' => 5, 166 | 'fixable' => false, 167 | 'column' => 8, 168 | 'source' => 'ImportDetection.Imports.RequireImports.Boom', 169 | 'line' => 18, 170 | 'message' => 'Found unused symbol Bar.', 171 | ], 172 | [ 173 | 'type' => 'WARNING', 174 | 'severity' => 5, 175 | 'fixable' => false, 176 | 'column' => 5, 177 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 178 | 'line' => 22, 179 | 'message' => 'Found unused symbol Foo.', 180 | ], 181 | ], 'fileA.php'); 182 | $messagesB = PhpcsMessages::fromArrays([ 183 | [ 184 | 'type' => 'WARNING', 185 | 'severity' => 5, 186 | 'fixable' => false, 187 | 'column' => 5, 188 | 'source' => 'ImportDetection.Imports.RequireImports.Zoop', 189 | 'line' => 30, 190 | 'message' => 'Found unused symbol Hi.', 191 | ], 192 | ], 'fileB.php'); 193 | $messages = PhpcsMessages::merge([$messagesA, $messagesB]); 194 | $expected = << 196 | 197 | 198 | Found unused symbol Faa. 199 | Found unused symbol Foo. 200 | Found unused symbol Bar. 201 | Found unused symbol Foo. 202 | 203 | 204 | Found unused symbol Hi. 205 | 206 | 207 | 208 | EOF; 209 | $options = new CliOptions(); 210 | $shell = new TestShell($options, []); 211 | $shell->registerExecutable('phpcs'); 212 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 213 | $reporter = new TestXmlReporter($options, $shell); 214 | $result = $reporter->getFormattedMessages($messages, ['s' => 1]); 215 | $this->assertEquals($expected, $result); 216 | } 217 | 218 | public function testNoWarnings() { 219 | $messages = PhpcsMessages::fromArrays([]); 220 | $expected = << 222 | 223 | 224 | 225 | 226 | 227 | EOF; 228 | $options = new CliOptions(); 229 | $shell = new TestShell($options, []); 230 | $shell->registerExecutable('phpcs'); 231 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 232 | $reporter = new TestXmlReporter($options, $shell); 233 | $result = $reporter->getFormattedMessages($messages, []); 234 | $this->assertEquals($expected, $result); 235 | } 236 | 237 | public function testSingleWarningWithNoFilename() { 238 | $messages = PhpcsMessages::fromArrays([ 239 | [ 240 | 'type' => 'WARNING', 241 | 'severity' => 5, 242 | 'fixable' => false, 243 | 'column' => 5, 244 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 245 | 'line' => 15, 246 | 'message' => 'Found unused symbol Foo.', 247 | ], 248 | ]); 249 | $expected = << 251 | 252 | 253 | Found unused symbol Foo. 254 | 255 | 256 | 257 | EOF; 258 | $options = new CliOptions(); 259 | $shell = new TestShell($options, []); 260 | $shell->registerExecutable('phpcs'); 261 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 262 | $reporter = new TestXmlReporter($options, $shell); 263 | $result = $reporter->getFormattedMessages($messages, []); 264 | $this->assertEquals($expected, $result); 265 | } 266 | 267 | public function testGetExitCodeWithMessages() { 268 | $messages = PhpcsMessages::fromArrays([ 269 | [ 270 | 'type' => 'WARNING', 271 | 'severity' => 5, 272 | 'fixable' => false, 273 | 'column' => 5, 274 | 'source' => 'ImportDetection.Imports.RequireImports.Import', 275 | 'line' => 15, 276 | 'message' => 'Found unused symbol Foo.', 277 | ], 278 | ], 'fileA.php'); 279 | $options = new CliOptions(); 280 | $shell = new TestShell($options, []); 281 | $shell->registerExecutable('phpcs'); 282 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 283 | $reporter = new TestXmlReporter($options, $shell); 284 | $this->assertEquals(1, $reporter->getExitCode($messages)); 285 | } 286 | 287 | public function testGetExitCodeWithNoMessages() { 288 | $messages = PhpcsMessages::fromArrays([], 'fileA.php'); 289 | $options = new CliOptions(); 290 | $shell = new TestShell($options, []); 291 | $shell->registerExecutable('phpcs'); 292 | $shell->registerCommand('phpcs --version', 'PHP_CodeSniffer version 1.2.3 (stable) by Squiz (http://www.squiz.net)'); 293 | $reporter = new TestXmlReporter($options, $shell); 294 | $this->assertEquals(0, $reporter->getExitCode($messages)); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Run [phpcs](https://github.com/squizlabs/PHP_CodeSniffer) on files and only report new warnings/errors compared to the previous version. 2 | 3 | This is both a PHP library that can be used manually as well as a CLI script that you can just run on your files. 4 | 5 | ## What is this for? 6 | 7 | Let's say that you need to add a feature to a large legacy file which has many phpcs errors. If you try to run phpcs on that file, there is so much noise it's impossible to notice any errors which you may have added yourself. 8 | 9 | Using this script you can get phpcs output which applies only to the changes you have made and ignores the unchanged errors. 10 | 11 | ## Installation 12 | 13 | ``` 14 | composer global require sirbrillig/phpcs-changed 15 | ``` 16 | 17 | ## CLI Usage 18 | 19 | 👩‍💻👩‍💻👩‍💻 20 | 21 | To make this work, you need to be able to provide data about the previous version of your code. `phpcs-changed` can get this data itself if you use svn or git, or you can provide it manually. 22 | 23 | Here's an example using `phpcs-changed` with the `--svn` option: 24 | 25 | ``` 26 | phpcs-changed --svn file.php 27 | ``` 28 | 29 | If you wanted to use svn and phpcs manually, this produces the same output: 30 | 31 | ``` 32 | svn diff file.php > file.php.diff 33 | svn cat file.php | phpcs --report=json -q > file.php.orig.phpcs 34 | cat file.php | phpcs --report=json -q > file.php.phpcs 35 | phpcs-changed --diff file.php.diff --phpcs-unmodified file.php.orig.phpcs --phpcs-modified file.php.phpcs 36 | ``` 37 | 38 | Both will output something like: 39 | 40 | ``` 41 | FILE: file.php 42 | ----------------------------------------------------------------------------------------------- 43 | FOUND 0 ERRORS AND 1 WARNING AFFECTING 1 LINE 44 | ----------------------------------------------------------------------------------------------- 45 | 76 | WARNING | Variable $foobar is undefined. 46 | ----------------------------------------------------------------------------------------------- 47 | ``` 48 | 49 | Or, with `--report json`: 50 | 51 | ```json 52 | { 53 | "totals": { 54 | "errors": 0, 55 | "warnings": 1, 56 | "fixable": 0 57 | }, 58 | "files": { 59 | "file.php": { 60 | "errors": 0, 61 | "warnings": 1, 62 | "messages": [ 63 | { 64 | "line": 76, 65 | "message": "Variable $foobar is undefined.", 66 | "source": "VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable", 67 | "severity": 5, 68 | "fixable": false, 69 | "type": "WARNING", 70 | "column": 8 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | If the file was versioned by git, we can do the same with the `--git` option: 79 | 80 | ``` 81 | phpcs-changed --git --git-unstaged file.php 82 | ``` 83 | 84 | When using `--git`, you should also specify `--git-staged`, `--git-unstaged`, or `--git-base`. 85 | 86 | `--git-staged` compares the currently staged changes (as the modified version of the files) to the current HEAD (as the unmodified version of the files). This is the default. 87 | 88 | `--git-unstaged` compares the current (unstaged) working copy changes (as the modified version of the files) to the either the currently staged changes, or if there are none, the current HEAD (as the unmodified version of the files). 89 | 90 | `--git-base`, followed by a git object, compares the current HEAD (as the modified version of the files) to the specified [git object](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) (as the unmodified version of the file) which can be a branch name, a commit, or some other valid git object. 91 | 92 | ``` 93 | git checkout add-new-feature 94 | phpcs-changed --git --git-base master file.php 95 | ``` 96 | 97 | ### CLI Options 98 | 99 | More than one file can be specified after a version control option, including globs and directories. If any file is a directory, phpcs-changed will scan the directory for all files ending in `.php` and process them. For example: `phpcs-changed --git src/lib test/**/*.php` will operate on all the php files in the `src/lib/` and `test/` directories. 100 | 101 | You can use `--ignore` to ignore any directory, file, or paths matching provided pattern(s). For example.: `--ignore=bin/*,vendor/*` would ignore any files in bin directory, as well as in vendor. 102 | 103 | You can use `--report` to customize the output type. `full` (the default) is human-readable, `json` prints a JSON object as shown above, and 'xml' can be used by IDEs. These match the phpcs reporters of the same names. `junit` can also be used for [JUnit XML](https://github.com/testmoapp/junitxml) which can be helpful for test runners. `checkstyle` will output Checkstyle format. 104 | 105 | You can use `--standard` to specify a specific phpcs standard to run. This matches the phpcs option of the same name. 106 | 107 | You can use `--extensions` to specify a list of valid file extensions that phpcs should check. These should be separated by commas. This matches the phpcs option of the same name. 108 | 109 | You can also use the `-s` option to Always show sniff codes after each error in the full reporter. This matches the phpcs option of the same name. 110 | 111 | The `--error-severity` and `--warning-severity` options can be used for instructing the `phpcs` command on what error and warning severity to report. Those values are being passed through to `phpcs` itself. Consult `phpcs` documentation for severity settings. 112 | 113 | The `--cache` option will enable caching of phpcs output and can significantly improve performance for slow phpcs standards or when running with high frequency. There are actually two caches: one for the phpcs scan of the unmodified version of the file and one for the phpcs scan of the modified version. The unmodified version phpcs output cache is invalidated when the version control revision changes or when the phpcs standard changes. The modified version phpcs output cache is invalidated when the file hash changes or when the phpcs standard changes. 114 | 115 | The `--no-cache` option will disable the cache if it's been enabled. (This may also be useful in the future if caching is made the default.) 116 | 117 | The `--clear-cache` option will clear the cache before running. This works with or without caching enabled. 118 | 119 | The `--always-exit-zero` option will make sure the run will always exit with `0` return code, no matter if there are lint issues or not. When not set, `1` is returned in case there are some lint issues, `0` if no lint issues were found. The flag makes the phpcs-changed working with other scripts which could detect `1` as failure in the script run (eg.: arcanist). 120 | 121 | The `--no-verify-git-file` option will prevent checking to see if a file is tracked by git during the git workflow. This can save a little time if you can guarantee this otherwise. 122 | 123 | The `--no-cache-git-root` option will prevent caching the check used by the git workflow to determine the git root within a single execution. This is probably only useful for automated tests. 124 | 125 | The `--arc-lint` option can be used when the phpcs-changed is run via arcanist, as it skips some checks, which are performed by arcanist itself. It leads to better performance when used with arcanist. (Equivalent to `--no-verify-git-file --always-exit-zero`.) 126 | 127 | The `--svn-path`, `--git-path`, `--cat-path`, and `--phpcs-path` options can be used to specify the paths to the executables of the same names. If these options are not set, the program will try to use the `SVN`, `GIT`, `CAT`, and `PHPCS` env variables. If those are also not set, the program will default to `svn`, `git`, `cat`, and `phpcs`, respectively, assuming that each command will be in the system's `PATH`. 128 | 129 | For phpcs, if the path is not overridden, and a `phpcs` executable exists under the `vendor/bin` directory where this command is run, that executable will be used instead of relying on the PATH. You can disable this feature with the `--no-vendor-phpcs` option. 130 | 131 | The `--debug` option will show every step taken by the script. 132 | 133 | ## PHP Library 134 | 135 | 🐘🐘🐘 136 | 137 | ### getNewPhpcsMessagesFromFiles 138 | 139 | This library exposes a function `PhpcsMessages\getNewPhpcsMessagesFromFiles()` which takes three arguments: 140 | 141 | - A file path containing the full unified diff of a single file. 142 | - A file path containing the messages resulting from running phpcs on the file before your recent changes. 143 | - A file path containing the messages resulting from running phpcs on the file after your recent changes. 144 | 145 | It will return an instance of `PhpcsMessages` which is a filtered list of the third argument above where every line that was present in the second argument has been removed. 146 | 147 | `PhpcsMessages` represents the output of running phpcs. 148 | 149 | To read the phpcs JSON output from an instance of `PhpcsMessages`, you can use the `toPhpcsJson()` method. For example: 150 | 151 | ```php 152 | use function PhpcsChanged\getNewPhpcsMessagesFromFiles; 153 | 154 | $changedMessages = getNewPhpcsMessagesFromFiles( 155 | $unifiedDiffFileName, 156 | $oldFilePhpcsOutputFileName, 157 | $newFilePhpcsOutputFileName 158 | ); 159 | 160 | echo $changedMessages->toPhpcsJson(); 161 | ``` 162 | 163 | This will output something like: 164 | 165 | ```json 166 | { 167 | "totals": { 168 | "errors": 0, 169 | "warnings": 1, 170 | "fixable": 0 171 | }, 172 | "files": { 173 | "file.php": { 174 | "errors": 0, 175 | "warnings": 1, 176 | "messages": [ 177 | { 178 | "line": 20, 179 | "type": "WARNING", 180 | "severity": 5, 181 | "fixable": false, 182 | "column": 5, 183 | "source": "ImportDetection.Imports.RequireImports.Import", 184 | "message": "Found unused symbol Foobar." 185 | } 186 | ] 187 | } 188 | } 189 | } 190 | ``` 191 | 192 | ### getNewPhpcsMessages 193 | 194 | If the previous function is not sufficient, this library exposes a lower-level function `PhpcsMessages\getNewPhpcsMessages()` which takes three arguments: 195 | 196 | - (string) The full unified diff of a single file. 197 | - (PhpcsMessages) The messages resulting from running phpcs on the file before your recent changes. 198 | - (PhpcsMessages) The messages resulting from running phpcs on the file after your recent changes. 199 | 200 | It will return an instance of `PhpcsMessages` which is a filtered list of the third argument above where every line that was present in the second argument has been removed. 201 | 202 | You can create an instance of `PhpcsMessages` from real phpcs JSON output by using `PhpcsMessages::fromPhpcsJson()`. The following example produces the same output as the previous one: 203 | 204 | ```php 205 | use function PhpcsChanged\getNewPhpcsMessages; 206 | use PhpcsChanged\PhpcsMessages; 207 | 208 | $changedMessages = getNewPhpcsMessages( 209 | $unifiedDiff, 210 | PhpcsMessages::fromPhpcsJson($oldFilePhpcsOutput), 211 | PhpcsMessages::fromPhpcsJson($newFilePhpcsOutput) 212 | ); 213 | 214 | echo $changedMessages->toPhpcsJson(); 215 | ``` 216 | 217 | ### Multiple files 218 | 219 | You can combine the results of `getNewPhpcsMessages` or `getNewPhpcsMessagesFromFiles` by using `PhpcsChanged\PhpcsMessages::merge()` which takes an array of `PhpcsMessages` instances and merges them into one instance. For example: 220 | 221 | ```php 222 | use function PhpcsChanged\getNewPhpcsMessages; 223 | use function PhpcsChanged\getNewPhpcsMessagesFromFiles; 224 | use PhpcsChanged\PhpcsMessages; 225 | 226 | $changedMessagesA = getNewPhpcsMessages( 227 | $unifiedDiffA, 228 | PhpcsMessages::fromPhpcsJson($oldFilePhpcsOutputA), 229 | PhpcsMessages::fromPhpcsJson($newFilePhpcsOutputA) 230 | ); 231 | $changedMessagesB = getNewPhpcsMessagesFromFiles( 232 | $unifiedDiffFileNameB, 233 | $oldFilePhpcsOutputFileNameB, 234 | $newFilePhpcsOutputFileNameB 235 | ); 236 | 237 | $changedMessages = PhpcsMessages::merge([$changedMessagesA, $changedMessagesB]); 238 | 239 | echo $changedMessages->toPhpcsJson(); 240 | ``` 241 | 242 | ## Running Tests 243 | 244 | Run the following commands in this directory to run the built-in test suite: 245 | 246 | ``` 247 | composer install 248 | composer test 249 | ``` 250 | 251 | You can also run linting and static analysis: 252 | 253 | ``` 254 | composer lint 255 | composer static-analysis 256 | ``` 257 | 258 | ## Debugging 259 | 260 | If something isn't working the way you expect, use the `--debug` option. This will show a considerable amount of output. Pay particular attention to the CLI commands run by the script. You can run these commands manually to try to better understand the issue. 261 | 262 | ## Inspiration 263 | 264 | This was inspired by the amazing work in https://github.com/Automattic/phpcs-diff 265 | -------------------------------------------------------------------------------- /PhpcsChanged/CliOptions.php: -------------------------------------------------------------------------------- 1 | files = $options['files']; 176 | } 177 | if (array_key_exists('no-vendor-phpcs', $options)) { 178 | $cliOptions->noVendorPhpcs = true; 179 | } 180 | if (array_key_exists('phpcs-path', $options)) { 181 | $cliOptions->phpcsPath = $options['phpcs-path']; 182 | } 183 | if (array_key_exists('git-path', $options)) { 184 | $cliOptions->gitPath = $options['git-path']; 185 | } 186 | if (array_key_exists('cat-path', $options)) { 187 | $cliOptions->catPath = $options['cat-path']; 188 | } 189 | if (array_key_exists('svn-path', $options)) { 190 | $cliOptions->svnPath = $options['svn-path']; 191 | } 192 | if (array_key_exists('svn', $options)) { 193 | $cliOptions->mode = Modes::SVN; 194 | } 195 | if (array_key_exists('git', $options)) { 196 | $cliOptions->mode = Modes::GIT_STAGED; 197 | } 198 | if (array_key_exists('git-unstaged', $options)) { 199 | $cliOptions->mode = Modes::GIT_UNSTAGED; 200 | } 201 | if (array_key_exists('git-staged', $options)) { 202 | $cliOptions->mode = Modes::GIT_STAGED; 203 | } 204 | if (array_key_exists('git-base', $options)) { 205 | $cliOptions->mode = Modes::GIT_BASE; 206 | $cliOptions->gitBase = $options['git-base']; 207 | } 208 | if (array_key_exists('report', $options)) { 209 | $cliOptions->reporter = $options['report']; 210 | } 211 | if (array_key_exists('debug', $options)) { 212 | $cliOptions->debug = true; 213 | } 214 | if (array_key_exists('clear-cache', $options)) { 215 | $cliOptions->clearCache = true; 216 | } 217 | if (array_key_exists('cache', $options)) { 218 | $cliOptions->useCache = true; 219 | } 220 | if (array_key_exists('no-cache', $options)) { 221 | $cliOptions->useCache = false; 222 | } 223 | if (array_key_exists('diff', $options)) { 224 | $cliOptions->mode = Modes::MANUAL; 225 | $cliOptions->diffFile = $options['diff']; 226 | } 227 | if (array_key_exists('phpcs-unmodified', $options)) { 228 | $cliOptions->mode = Modes::MANUAL; 229 | $cliOptions->phpcsUnmodified = $options['phpcs-unmodified']; 230 | } 231 | if (array_key_exists('phpcs-modified', $options)) { 232 | $cliOptions->mode = Modes::MANUAL; 233 | $cliOptions->phpcsModified = $options['phpcs-modified']; 234 | } 235 | if (array_key_exists('s', $options)) { 236 | $cliOptions->showMessageCodes = true; 237 | } 238 | if (array_key_exists('standard', $options)) { 239 | $cliOptions->phpcsStandard = $options['standard']; 240 | } 241 | if (array_key_exists('extensions', $options)) { 242 | $cliOptions->phpcsExtensions = $options['extensions']; 243 | } 244 | if (array_key_exists('always-exit-zero', $options)) { 245 | $cliOptions->alwaysExitZero = true; 246 | } 247 | if (array_key_exists('no-cache-git-root', $options)) { 248 | $cliOptions->noCacheGitRoot = true; 249 | } 250 | if (array_key_exists('no-verify-git-file', $options)) { 251 | $cliOptions->noVerifyGitFile = true; 252 | } 253 | if (array_key_exists('warning-severity', $options)) { 254 | $cliOptions->warningSeverity = $options['warning-severity']; 255 | } 256 | if (array_key_exists('error-severity', $options)) { 257 | $cliOptions->errorSeverity = $options['error-severity']; 258 | } 259 | if (array_key_exists('i', $options)) { 260 | $cliOptions->mode = Modes::INFO_ONLY; 261 | } 262 | $cliOptions->validate(); 263 | return $cliOptions; 264 | } 265 | 266 | public function toArray(): array { 267 | $options = []; 268 | $options['report'] = $this->reporter; 269 | $options['files'] = $this->files; 270 | if (boolval($this->phpcsStandard)) { 271 | $options['standard'] = $this->phpcsStandard; 272 | } 273 | if (boolval($this->phpcsExtensions)) { 274 | $options['extensions'] = $this->phpcsExtensions; 275 | } 276 | if (boolval($this->noVendorPhpcs)) { 277 | $options['no-vendor-phpcs'] = true; 278 | } 279 | if (boolval($this->phpcsPath)) { 280 | $options['phpcs-path'] = $this->phpcsPath; 281 | } 282 | if (boolval($this->gitPath)) { 283 | $options['git-path'] = $this->gitPath; 284 | } 285 | if (boolval($this->catPath)) { 286 | $options['cat-path'] = $this->catPath; 287 | } 288 | if (boolval($this->svnPath)) { 289 | $options['svn-path'] = $this->svnPath; 290 | } 291 | if (boolval($this->debug)) { 292 | $options['debug'] = true; 293 | } 294 | if (boolval($this->showMessageCodes)) { 295 | $options['s'] = true; 296 | } 297 | if ($this->mode === Modes::SVN) { 298 | $options['svn'] = true; 299 | } 300 | if ($this->mode === Modes::GIT_STAGED) { 301 | $options['git'] = true; 302 | $options['git-staged'] = true; 303 | } 304 | if ($this->mode === Modes::GIT_UNSTAGED) { 305 | $options['git'] = true; 306 | $options['git-unstaged'] = true; 307 | } 308 | if ($this->mode === Modes::GIT_BASE) { 309 | $options['git'] = true; 310 | $options['git-base'] = $this->gitBase; 311 | } 312 | if (boolval($this->useCache)) { 313 | $options['cache'] = true; 314 | } 315 | if (! boolval($this->useCache)) { 316 | $options['no-cache'] = true; 317 | } 318 | if (boolval($this->clearCache)) { 319 | $options['clear-cache'] = true; 320 | } 321 | if ($this->mode === Modes::MANUAL) { 322 | $options['diff'] = $this->diffFile; 323 | $options['phpcs-unmodified'] = $this->phpcsUnmodified; 324 | $options['phpcs-modified'] = $this->phpcsModified; 325 | } 326 | if (boolval($this->alwaysExitZero)) { 327 | $options['always-exit-zero'] = true; 328 | } 329 | if (boolval($this->noCacheGitRoot)) { 330 | $options['no-cache-git-root'] = true; 331 | } 332 | if (boolval($this->noVerifyGitFile)) { 333 | $options['no-verify-git-file'] = true; 334 | } 335 | // Note that both warningSeverity and errorSeverity can be the string '0' 336 | // which is falsy in PHP but is a valid value here so we must be careful 337 | // when testing for it. 338 | if (is_string($this->warningSeverity) && strlen($this->warningSeverity) > 0) { 339 | $options['warning-severity'] = $this->warningSeverity; 340 | } 341 | if (is_string($this->errorSeverity) && strlen($this->errorSeverity) > 0) { 342 | $options['error-severity'] = $this->errorSeverity; 343 | } 344 | return $options; 345 | } 346 | 347 | public function isGitMode(): bool { 348 | $gitModes = [Modes::GIT_BASE, Modes::GIT_UNSTAGED, Modes::GIT_STAGED]; 349 | return in_array($this->mode, $gitModes, true); 350 | } 351 | 352 | public function getExecutablePath(string $executableName): string { 353 | switch ($executableName) { 354 | case 'phpcs': 355 | if (is_string($this->phpcsPath) && strlen($this->phpcsPath) > 0) { 356 | return $this->phpcsPath; 357 | } 358 | $env = getenv('PHPCS'); 359 | if (is_string($env) && strlen($env) > 0) { 360 | return $env; 361 | } 362 | return 'phpcs'; 363 | case 'git': 364 | if (is_string($this->gitPath) && strlen($this->gitPath) > 0) { 365 | return $this->gitPath; 366 | } 367 | $env = getenv('GIT'); 368 | if (is_string($env) && strlen($env) > 0) { 369 | return $env; 370 | } 371 | return 'git'; 372 | case 'cat': 373 | if (is_string($this->catPath) && strlen($this->catPath) > 0) { 374 | return $this->catPath; 375 | } 376 | $env = getenv('CAT'); 377 | if (is_string($env) && strlen($env) > 0) { 378 | return $env; 379 | } 380 | return 'cat'; 381 | case 'svn': 382 | if (is_string($this->svnPath) && strlen($this->svnPath) > 0) { 383 | return $this->svnPath; 384 | } 385 | $env = getenv('SVN'); 386 | if (is_string($env) && strlen($env) > 0) { 387 | return $env; 388 | } 389 | return 'svn'; 390 | default: 391 | throw new \Exception("No executable found called '{$executableName}'."); 392 | } 393 | } 394 | 395 | public function validate(): void { 396 | if (! boolval($this->mode)) { 397 | throw new InvalidOptionException('You must use either automatic or manual mode.'); 398 | } 399 | if ($this->mode === Modes::MANUAL) { 400 | if ( ! boolval($this->diffFile) || ! boolval($this->phpcsUnmodified) || ! boolval($this->phpcsModified)) { 401 | throw new InvalidOptionException('Manual mode requires a diff, the unmodified file phpcs output, and the modified file phpcs output.'); 402 | } 403 | } 404 | if ($this->mode === Modes::GIT_BASE && ! boolval($this->gitBase)) { 405 | throw new InvalidOptionException('git-base mode requires a git object.'); 406 | } 407 | if ($this->isGitMode() && ! boolval($this->files)) { 408 | throw new InvalidOptionException('You must supply at least one file or directory to run in git mode.'); 409 | } 410 | if ($this->mode === Modes::SVN && ! boolval($this->files)) { 411 | throw new InvalidOptionException('You must supply at least one file or directory to run in svn mode.'); 412 | } 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /tests/PhpcsChangedTest.php: -------------------------------------------------------------------------------- 1 | 20 ], 28 | [ 'line' => 99 ], 29 | [ 'line' => 108 ], 30 | [ 'line' => 111 ], 31 | [ 'line' => 114 ], 32 | ]; 33 | $newFilePhpcs = [ 34 | [ 'line' => 20 ], 35 | [ 'line' => 21 ], 36 | [ 'line' => 100 ], 37 | [ 'line' => 109 ], 38 | [ 'line' => 112 ], 39 | [ 'line' => 115 ], 40 | ]; 41 | $actual = getNewPhpcsMessages($diff, PhpcsMessages::fromArrays($oldFilePhpcs), PhpcsMessages::fromArrays($newFilePhpcs)); 42 | $expected = PhpcsMessages::fromArrays([ 43 | [ 'line' => 20 ], 44 | ]); 45 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers()); 46 | } 47 | 48 | public function testGetNewPhpcsMessagesWithNewFile() { 49 | $diff = << 3 ], 62 | ]; 63 | $actual = getNewPhpcsMessages($diff, PhpcsMessages::fromArrays($oldFilePhpcs), PhpcsMessages::fromArrays($newFilePhpcs)); 64 | $expected = PhpcsMessages::fromArrays([ 65 | [ 'line' => 3 ], 66 | ]); 67 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers()); 68 | } 69 | 70 | public function testGetNewPhpcsMessagesWithChangedLine() { 71 | $diff = << 20 ], 88 | ]; 89 | $newFilePhpcs = [ 90 | [ 'line' => 20 ], 91 | ]; 92 | $actual = getNewPhpcsMessages($diff, PhpcsMessages::fromArrays($oldFilePhpcs), PhpcsMessages::fromArrays($newFilePhpcs)); 93 | $expected = PhpcsMessages::fromArrays([ 94 | [ 'line' => 20 ], 95 | ]); 96 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers()); 97 | } 98 | 99 | public function testGetNewPhpcsMessagesHasFileName() { 100 | $diff = << 20 ], 116 | [ 'line' => 99 ], 117 | [ 'line' => 108 ], 118 | [ 'line' => 111 ], 119 | [ 'line' => 114 ], 120 | ]; 121 | $newFilePhpcs = [ 122 | [ 'line' => 20 ], 123 | [ 'line' => 21 ], 124 | [ 'line' => 100 ], 125 | [ 'line' => 109 ], 126 | [ 'line' => 112 ], 127 | [ 'line' => 115 ], 128 | ]; 129 | $actual = getNewPhpcsMessages( 130 | $diff, 131 | PhpcsMessages::fromArrays($oldFilePhpcs), 132 | PhpcsMessages::fromArrays($newFilePhpcs) 133 | ); 134 | $this->assertEquals('bin/review-stuck-orders.php', $actual->getMessages()[0]->getFile()); 135 | } 136 | 137 | public function testGetNewPhpcsMessagesFromFiles() { 138 | $actual = getNewPhpcsMessagesFromFiles( 139 | 'tests/fixtures/review-stuck-orders.diff', 140 | 'tests/fixtures/old-phpcs-output.json', 141 | 'tests/fixtures/new-phpcs-output.json' 142 | ); 143 | $expected = PhpcsMessages::fromArrays([ 144 | [ 'line' => 20 ], 145 | ]); 146 | $this->assertEquals($expected->getLineNumbers(), $actual->getLineNumbers()); 147 | } 148 | 149 | public function testGetNewPhpcsMessagesFromFilesHasFileName() { 150 | $actual = getNewPhpcsMessagesFromFiles( 151 | 'tests/fixtures/review-stuck-orders.diff', 152 | 'tests/fixtures/old-phpcs-output.json', 153 | 'tests/fixtures/new-phpcs-output.json' 154 | ); 155 | $this->assertEquals('bin/review-stuck-orders.php', $actual->getMessages()[0]->getFile()); 156 | } 157 | 158 | 159 | public function testGetNewPhpcsMessagesWithPhpcsJson() { 160 | $diff = <<toPhpcsJson(); 177 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}'; 178 | $this->assertEquals($expected, $actual); 179 | } 180 | 181 | public function testGetNewPhpcsMessagesWithPhpcsJsonAndNewFile() { 182 | $diff = <<toPhpcsJson(); 195 | $expected = '{"totals":{"errors":0,"warnings":2,"fixable":0},"files":{"foo.php":{"errors":0,"warnings":2,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."},{"line":21,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}'; 196 | $this->assertEquals($expected, $actual); 197 | } 198 | 199 | public function testGetNewPhpcsMessagesWithPhpcsJsonHasFileNameIfProvided() { 200 | $diff = <<toPhpcsJson(); 218 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}'; 219 | $this->assertEquals($expected, $actual); 220 | } 221 | 222 | public function testGetNewPhpcsMessagesWithPhpcsJsonAndFilename() { 223 | $diff = <<toPhpcsJson(); 240 | $expected = '{"totals":{"errors":0,"warnings":1,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":0,"warnings":1,"messages":[{"line":20,"type":"WARNING","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}'; 241 | $this->assertEquals($expected, $actual); 242 | } 243 | 244 | public function testGetNewPhpcsMessagesWithPhpcsJsonAndErrors() { 245 | $diff = <<toPhpcsJson(); 262 | $expected = '{"totals":{"errors":1,"warnings":0,"fixable":0},"files":{"bin/review-stuck-orders.php":{"errors":1,"warnings":0,"messages":[{"line":20,"type":"ERROR","severity":5,"fixable":false,"column":5,"source":"ImportDetection.Imports.RequireImports.Import","message":"Found unused symbol Emergent."}]}}}'; 263 | $this->assertEquals($expected, $actual); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /PhpcsChanged/UnixShell.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private $fullPaths = []; 27 | 28 | /** 29 | * The output of `svn info` for each svn file keyed by filename. 30 | * 31 | * @var Array 32 | */ 33 | private $svnInfo = []; 34 | 35 | public function __construct(CliOptions $options) { 36 | $this->options = $options; 37 | } 38 | 39 | #[\Override] 40 | public function clearCaches(): void { 41 | $this->fullPaths = []; 42 | $this->svnInfo = []; 43 | } 44 | 45 | #[\Override] 46 | public function validateShellIsReady(): void { 47 | if ($this->options->mode === Modes::MANUAL) { 48 | $phpcs = $this->getPhpcsExecutable(); 49 | $this->validateExecutableExists('phpcs', $phpcs); 50 | } 51 | 52 | if ($this->options->mode === Modes::SVN) { 53 | $cat = $this->options->getExecutablePath('cat'); 54 | $svn = $this->options->getExecutablePath('svn'); 55 | $this->validateExecutableExists('svn', $svn); 56 | $this->validateExecutableExists('cat', $cat); 57 | $phpcs = $this->getPhpcsExecutable(); 58 | $this->validateExecutableExists('phpcs', $phpcs); 59 | } 60 | 61 | if ($this->options->isGitMode()) { 62 | $git = $this->options->getExecutablePath('git'); 63 | $this->validateExecutableExists('git', $git); 64 | $phpcs = $this->getPhpcsExecutable(); 65 | $this->validateExecutableExists('phpcs', $phpcs); 66 | } 67 | } 68 | 69 | protected function validateExecutableExists(string $name, string $command): void { 70 | exec(sprintf("type %s > /dev/null 2>&1", escapeshellarg($command)), $ignore, $returnVal); 71 | if ($returnVal != 0) { 72 | throw new \Exception("Cannot find executable for {$name}, currently set to '{$command}'."); 73 | } 74 | } 75 | 76 | private function getPhpcsExecutable(): string { 77 | if (boolval($this->options->phpcsPath) || boolval(getenv('PHPCS'))) { 78 | return $this->options->getExecutablePath('phpcs'); 79 | } 80 | if (! $this->options->noVendorPhpcs && $this->doesPhpcsExistInVendor()) { 81 | return $this->getVendorPhpcsPath(); 82 | } 83 | return 'phpcs'; 84 | } 85 | 86 | private function doesPhpcsExistInVendor(): bool { 87 | try { 88 | $this->validateExecutableExists('phpcs', $this->getVendorPhpcsPath()); 89 | } catch (\Exception $err) { 90 | return false; 91 | } 92 | return true; 93 | } 94 | 95 | private function getVendorPhpcsPath(): string { 96 | return 'vendor/bin/phpcs'; 97 | } 98 | 99 | protected function executeCommand(string $command, ?int &$return_val = null): string { 100 | $output = []; 101 | exec($command, $output, $return_val); 102 | return implode(PHP_EOL, $output) . PHP_EOL; 103 | } 104 | 105 | #[\Override] 106 | public function getPhpcsStandards(): string { 107 | $phpcs = $this->getPhpcsExecutable(); 108 | $installedCodingStandardsPhpcsOutputCommand = "{$phpcs} -i"; 109 | return $this->executeCommand($installedCodingStandardsPhpcsOutputCommand); 110 | } 111 | 112 | private function doesFileExistInGitBase(string $fileName): bool { 113 | $debug = getDebug($this->options->debug); 114 | $git = $this->options->getExecutablePath('git'); 115 | $gitStatusCommand = "{$git} cat-file -e " . escapeshellarg($this->options->gitBase) . ':' . escapeshellarg($this->getFullGitPathToFile($fileName)) . ' 2>/dev/null'; 116 | $debug('checking status of file with command:', $gitStatusCommand); 117 | /** @var int */ 118 | $return_val = 1; 119 | $gitStatusOutput = $this->executeCommand($gitStatusCommand, $return_val); 120 | $debug('status command output:', $gitStatusOutput); 121 | $debug('status command return val:', $return_val); 122 | return 0 !== $return_val; 123 | } 124 | 125 | private function getGitStatusForFile(string $fileName): string { 126 | $debug = getDebug($this->options->debug); 127 | $git = $this->options->getExecutablePath('git'); 128 | $gitStatusCommand = "{$git} status --porcelain " . escapeshellarg($fileName); 129 | $debug('checking git status of file with command:', $gitStatusCommand); 130 | $gitStatusOutput = $this->executeCommand($gitStatusCommand); 131 | $debug('git status output:', $gitStatusOutput); 132 | return $gitStatusOutput; 133 | } 134 | 135 | private function isFileStagedForAdding(string $fileName): bool { 136 | $gitStatusOutput = $this->getGitStatusForFile($fileName); 137 | // The git status will be empty for tracked, unchanged files. 138 | if (! $gitStatusOutput || false === strpos($gitStatusOutput, $fileName)) { 139 | return false; 140 | } 141 | if (isset($gitStatusOutput[0]) && $gitStatusOutput[0] === '?') { 142 | throw new ShellException("File does not appear to be tracked by git: '{$fileName}'"); 143 | } 144 | return isset($gitStatusOutput[0]) && $gitStatusOutput[0] === 'A'; 145 | } 146 | 147 | #[\Override] 148 | public function doesUnmodifiedFileExistInGit(string $fileName): bool { 149 | if ($this->options->mode === Modes::GIT_BASE) { 150 | return $this->doesFileExistInGitBase($fileName); 151 | } 152 | return $this->isFileStagedForAdding($fileName); 153 | } 154 | 155 | private function getFullGitPathToFile(string $fileName): string { 156 | // Return cache if set. 157 | if (array_key_exists($fileName, $this->fullPaths)) { 158 | return $this->fullPaths[$fileName]; 159 | } 160 | 161 | $debug = getDebug($this->options->debug); 162 | $git = $this->options->getExecutablePath('git'); 163 | 164 | // Verify that the file exists in git before we try to get its full path. 165 | // There's never a case where we'd be scanning a modified file that is not 166 | // tracked by git (a new file must be staged because otherwise we wouldn't 167 | // know it exists). 168 | if (! $this->options->noVerifyGitFile) { 169 | $gitStatusOutput = $this->getGitStatusForFile($fileName); 170 | // The git status will be empty for tracked, unchanged files. 171 | if (isset($gitStatusOutput[0]) && $gitStatusOutput[0] === '?') { 172 | throw new ShellException("File does not appear to be tracked by git: '{$fileName}'"); 173 | } 174 | } 175 | 176 | $command = "{$git} ls-files --full-name " . escapeshellarg($fileName); 177 | $debug('getting full path to file with command:', $command); 178 | $fullPath = trim($this->executeCommand($command)); 179 | 180 | // This will not change so we can cache it. 181 | $this->fullPaths[$fileName] = $fullPath; 182 | return $fullPath; 183 | } 184 | 185 | private function getModifiedFileContentsCommand(string $fileName): string { 186 | $git = $this->options->getExecutablePath('git'); 187 | $cat = $this->options->getExecutablePath('cat'); 188 | $fullPath = $this->getFullGitPathToFile($fileName); 189 | if ($this->options->mode === Modes::GIT_BASE) { 190 | // for git-base mode, we get the contents of the file from the HEAD version of the file in the current branch 191 | return "{$git} show HEAD:" . escapeshellarg($fullPath); 192 | } 193 | if ($this->options->mode === Modes::GIT_UNSTAGED) { 194 | // for git-unstaged mode, we get the contents of the file from the current working copy 195 | return "{$cat} " . escapeshellarg($fileName); 196 | } 197 | // default mode is git-staged, so we get the contents from the staged version of the file 198 | return "{$git} show :0:" . escapeshellarg($fullPath); 199 | } 200 | 201 | private function getUnmodifiedFileContentsCommand(string $fileName): string { 202 | $git = $this->options->getExecutablePath('git'); 203 | if ($this->options->mode === Modes::GIT_BASE) { 204 | $rev = escapeshellarg($this->options->gitBase); 205 | } else if ($this->options->mode === Modes::GIT_UNSTAGED) { 206 | $rev = ':0'; // :0 in this case means "staged version or HEAD if there is no staged version" 207 | } else { 208 | // git-staged is the default 209 | $rev = 'HEAD'; 210 | } 211 | $fullPath = $this->getFullGitPathToFile($fileName); 212 | return "{$git} show {$rev}:" . escapeshellarg($fullPath); 213 | } 214 | 215 | #[\Override] 216 | public function getGitHashOfModifiedFile(string $fileName): string { 217 | $debug = getDebug($this->options->debug); 218 | $git = $this->options->getExecutablePath('git'); 219 | $fileContentsCommand = $this->getModifiedFileContentsCommand($fileName); 220 | $command = "{$fileContentsCommand} | {$git} hash-object --stdin"; 221 | $debug('running modified file git hash command:', $command); 222 | $hash = $this->executeCommand($command); 223 | if (! $hash) { 224 | throw new ShellException("Cannot get modified file hash for file '{$fileName}'"); 225 | } 226 | $debug('modified file git hash command output:', $hash); 227 | return $hash; 228 | } 229 | 230 | #[\Override] 231 | public function getGitHashOfUnmodifiedFile(string $fileName): string { 232 | $debug = getDebug($this->options->debug); 233 | $git = $this->options->getExecutablePath('git'); 234 | $fileContentsCommand = $this->getUnmodifiedFileContentsCommand($fileName); 235 | $command = "{$fileContentsCommand} | {$git} hash-object --stdin"; 236 | $debug('running unmodified file git hash command:', $command); 237 | $hash = $this->executeCommand($command); 238 | if (! $hash) { 239 | throw new ShellException("Cannot get unmodified file hash for file '{$fileName}'"); 240 | } 241 | $debug('unmodified file git hash command output:', $hash); 242 | return $hash; 243 | } 244 | 245 | private function getPhpcsStandardOption(): string { 246 | $phpcsStandard = $this->options->phpcsStandard ?? ''; 247 | $phpcsStandardOption = strlen($phpcsStandard) > 0 ? ' --standard=' . escapeshellarg($phpcsStandard) : ''; 248 | $warningSeverity = $this->options->warningSeverity ?? ''; 249 | $phpcsStandardOption .= strlen($warningSeverity) > 0 ? ' --warning-severity=' . escapeshellarg($warningSeverity) : ''; 250 | $errorSeverity = $this->options->errorSeverity ?? ''; 251 | $phpcsStandardOption .= strlen($errorSeverity) > 0 ? ' --error-severity=' . escapeshellarg($errorSeverity) : ''; 252 | return $phpcsStandardOption; 253 | } 254 | 255 | private function getPhpcsExtensionsOption(): string { 256 | $phpcsExtensions = $this->options->phpcsExtensions ?? ''; 257 | $phpcsExtensionsOption = strlen($phpcsExtensions) > 0 ? ' --extensions=' . escapeshellarg($phpcsExtensions) : ''; 258 | return $phpcsExtensionsOption; 259 | } 260 | 261 | #[\Override] 262 | public function getPhpcsOutputOfModifiedGitFile(string $fileName): string { 263 | $debug = getDebug($this->options->debug); 264 | $fileContentsCommand = $this->getModifiedFileContentsCommand($fileName); 265 | $modifiedFilePhpcsOutputCommand = "{$fileContentsCommand} | " . $this->getPhpcsCommand($fileName); 266 | $debug('running modified file phpcs command:', $modifiedFilePhpcsOutputCommand); 267 | $modifiedFilePhpcsOutput = $this->executeCommand($modifiedFilePhpcsOutputCommand); 268 | return $this->processPhpcsOutput($fileName, 'modified', $modifiedFilePhpcsOutput); 269 | } 270 | 271 | #[\Override] 272 | public function getPhpcsOutputOfUnmodifiedGitFile(string $fileName): string { 273 | $debug = getDebug($this->options->debug); 274 | $unmodifiedFileContentsCommand = $this->getUnmodifiedFileContentsCommand($fileName); 275 | $unmodifiedFilePhpcsOutputCommand = "{$unmodifiedFileContentsCommand} | " . $this->getPhpcsCommand($fileName); 276 | $debug('running unmodified file phpcs command:', $unmodifiedFilePhpcsOutputCommand); 277 | $unmodifiedFilePhpcsOutput = $this->executeCommand($unmodifiedFilePhpcsOutputCommand); 278 | return $this->processPhpcsOutput($fileName, 'unmodified', $unmodifiedFilePhpcsOutput); 279 | } 280 | 281 | #[\Override] 282 | public function getGitUnifiedDiff(string $fileName): string { 283 | $debug = getDebug($this->options->debug); 284 | $git = $this->options->getExecutablePath('git'); 285 | $objectOption = $this->options->mode === Modes::GIT_BASE ? ' ' . escapeshellarg($this->options->gitBase) . '...' : ''; 286 | $stagedOption = ! boolval($objectOption) && $this->options->mode !== Modes::GIT_UNSTAGED ? ' --staged' : ''; 287 | $unifiedDiffCommand = "{$git} diff{$stagedOption}{$objectOption} --no-prefix " . escapeshellarg($fileName); 288 | $debug('running diff command:', $unifiedDiffCommand); 289 | $unifiedDiff = $this->executeCommand($unifiedDiffCommand); 290 | if (! $unifiedDiff) { 291 | throw new NoChangesException("Cannot get git diff for file '{$fileName}'; skipping"); 292 | } 293 | $debug('diff command output:', $unifiedDiff); 294 | return $unifiedDiff; 295 | } 296 | 297 | #[\Override] 298 | public function getGitMergeBase(): string { 299 | if ($this->options->mode !== Modes::GIT_BASE) { 300 | return ''; 301 | } 302 | $debug = getDebug($this->options->debug); 303 | $git = $this->options->getExecutablePath('git'); 304 | $mergeBaseCommand = "{$git} merge-base " . escapeshellarg($this->options->gitBase) . ' HEAD'; 305 | $debug('running merge-base command:', $mergeBaseCommand); 306 | $mergeBase = $this->executeCommand($mergeBaseCommand); 307 | if (! $mergeBase) { 308 | $debug('merge-base command produced no output'); 309 | return $this->options->gitBase; 310 | } 311 | $debug('merge-base command output:', $mergeBase); 312 | return trim($mergeBase); 313 | } 314 | 315 | #[\Override] 316 | public function getPhpcsOutputOfModifiedSvnFile(string $fileName): string { 317 | $debug = getDebug($this->options->debug); 318 | $cat = $this->options->getExecutablePath('cat'); 319 | $modifiedFilePhpcsOutputCommand = "{$cat} " . escapeshellarg($fileName) . ' | ' . $this->getPhpcsCommand($fileName); 320 | $debug('running modified file phpcs command:', $modifiedFilePhpcsOutputCommand); 321 | $modifiedFilePhpcsOutput = $this->executeCommand($modifiedFilePhpcsOutputCommand); 322 | return $this->processPhpcsOutput($fileName, 'modified', $modifiedFilePhpcsOutput); 323 | } 324 | 325 | #[\Override] 326 | public function getPhpcsOutputOfUnmodifiedSvnFile(string $fileName): string { 327 | $debug = getDebug($this->options->debug); 328 | $svn = $this->options->getExecutablePath('svn'); 329 | $unmodifiedFilePhpcsOutputCommand = "{$svn} cat " . escapeshellarg($fileName) . " | " . $this->getPhpcsCommand($fileName); 330 | $debug('running unmodified file phpcs command:', $unmodifiedFilePhpcsOutputCommand); 331 | $unmodifiedFilePhpcsOutput = $this->executeCommand($unmodifiedFilePhpcsOutputCommand); 332 | return $this->processPhpcsOutput($fileName, 'unmodified', $unmodifiedFilePhpcsOutput); 333 | } 334 | 335 | private function getPhpcsCommand(string $fileName): string { 336 | $phpcs = $this->getPhpcsExecutable(); 337 | return "{$phpcs} --report=json -q" . $this->getPhpcsStandardOption() . $this->getPhpcsExtensionsOption() . ' --stdin-path=' . escapeshellarg($fileName) . ' -'; 338 | } 339 | 340 | private function processPhpcsOutput(string $fileName, string $modifiedOrUnmodified, string $phpcsOutput): string { 341 | $debug = getDebug($this->options->debug); 342 | if (! $phpcsOutput) { 343 | throw new ShellException("Cannot get {$modifiedOrUnmodified} file phpcs output for file '{$fileName}'"); 344 | } 345 | $debug("{$modifiedOrUnmodified} file phpcs command output:", $phpcsOutput); 346 | if (false !== strpos($phpcsOutput, 'You must supply at least one file or directory to process')) { 347 | $debug("phpcs output implies {$modifiedOrUnmodified} file is empty"); 348 | return ''; 349 | } 350 | return $phpcsOutput; 351 | } 352 | 353 | #[\Override] 354 | public function doesUnmodifiedFileExistInSvn(string $fileName): bool { 355 | $svnFileInfo = $this->getSvnFileInfo($fileName); 356 | return (false !== strpos($svnFileInfo, 'Schedule: add')); 357 | } 358 | 359 | #[\Override] 360 | public function getSvnRevisionId(string $fileName): string { 361 | $svnFileInfo = $this->getSvnFileInfo($fileName); 362 | preg_match('/\bLast Changed Rev:\s([^\n]+)/', $svnFileInfo, $matches); 363 | // New files will not have a revision 364 | return $matches[1] ?? ''; 365 | } 366 | 367 | private function getSvnFileInfo(string $fileName): string { 368 | // Return cache if set. 369 | if (array_key_exists($fileName, $this->svnInfo)) { 370 | return $this->svnInfo[$fileName]; 371 | } 372 | $debug = getDebug($this->options->debug); 373 | $svn = $this->options->getExecutablePath('svn'); 374 | $svnStatusCommand = "{$svn} info " . escapeshellarg($fileName); 375 | $debug('checking svn status of file with command:', $svnStatusCommand); 376 | $svnStatusOutput = $this->executeCommand($svnStatusCommand); 377 | $debug('svn status output:', $svnStatusOutput); 378 | if (! $svnStatusOutput || false === strpos($svnStatusOutput, 'Schedule:')) { 379 | throw new ShellException("Cannot get svn info for file '{$fileName}'"); 380 | } 381 | // This will not change within a run so we can cache it. 382 | $this->svnInfo[$fileName] = $svnStatusOutput; 383 | return $svnStatusOutput; 384 | } 385 | 386 | #[\Override] 387 | public function getSvnUnifiedDiff(string $fileName): string { 388 | $debug = getDebug($this->options->debug); 389 | $svn = $this->options->getExecutablePath('svn'); 390 | $unifiedDiffCommand = "{$svn} diff " . escapeshellarg($fileName); 391 | $debug('running diff command:', $unifiedDiffCommand); 392 | $unifiedDiff = $this->executeCommand($unifiedDiffCommand); 393 | if (! $unifiedDiff) { 394 | throw new NoChangesException("Cannot get svn diff for file '{$fileName}'; skipping"); 395 | } 396 | $debug('diff command output:', $unifiedDiff); 397 | return $unifiedDiff; 398 | } 399 | 400 | #[\Override] 401 | public function isReadable(string $fileName): bool { 402 | return is_readable($fileName); 403 | } 404 | 405 | #[\Override] 406 | public function getFileHash(string $fileName): string { 407 | $result = md5_file($fileName); 408 | if ($result === false) { 409 | throw new \Exception("Cannot get hash for file '{$fileName}'."); 410 | } 411 | return $result; 412 | } 413 | 414 | #[\Override] 415 | public function exitWithCode(int $code): void { 416 | exit($code); 417 | } 418 | 419 | #[\Override] 420 | public function printError(string $message): void { 421 | printError($message); 422 | } 423 | 424 | #[\Override] 425 | public function getFileNameFromPath(string $path): string { 426 | $parts = explode('/', $path); 427 | return end($parts); 428 | } 429 | 430 | #[\Override] 431 | public function getPhpcsVersion(): string { 432 | $phpcs = $this->getPhpcsExecutable(); 433 | 434 | $versionPhpcsOutputCommand = "{$phpcs} --version"; 435 | $versionPhpcsOutput = $this->executeCommand($versionPhpcsOutputCommand); 436 | if (! $versionPhpcsOutput) { 437 | throw new ShellException("Cannot get phpcs version"); 438 | } 439 | 440 | $matched = preg_match('/version\\s([0-9.]+)/uim', $versionPhpcsOutput, $matches); 441 | if ( 442 | $matched === false 443 | || count($matches) < 2 444 | || strlen($matches[1]) < 1 445 | ) { 446 | throw new ShellException("Cannot parse phpcs version output"); 447 | } 448 | 449 | return $matches[1]; 450 | } 451 | } 452 | --------------------------------------------------------------------------------