├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .tool-versions ├── .typos.toml ├── LICENSE.md ├── composer.json ├── docs ├── example.png └── github-actions.png ├── extension.neon ├── phpunit.xml └── src ├── CodeHighlight ├── CodeHighlighter.php └── FallbackHighlighter.php ├── Config └── FriendlyFormatterConfig.php ├── ErrorFormat ├── ErrorWriter.php └── SummaryWriter.php └── FriendlyErrorFormatter.php /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/php:8.4 2 | 3 | # PHP memory limit 4 | RUN echo "memory_limit=768M" > /usr/local/etc/php/php.ini 5 | 6 | COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PHP", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | 7 | // Set *default* container specific settings.json values on container create. 8 | "settings": { 9 | "terminal.integrated.shell.linux": "/bin/bash" 10 | }, 11 | 12 | // Add the IDs of extensions you want installed when the container is created. 13 | "extensions": [ 14 | "felixfbecker.php-debug", 15 | "felixfbecker.php-intellisense", 16 | "mrmlnc.vscode-apache" 17 | ], 18 | 19 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 20 | "forwardPorts": [8080], 21 | 22 | // Use 'postCreateCommand' to run commands after the container is created. 23 | "postCreateCommand": "composer install" 24 | 25 | // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. 26 | // "remoteUser": "vscode" 27 | } 28 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | php 8.4.1 2 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | ".git/", 4 | ] 5 | ignore-hidden = false 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kazuki Yamada 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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yamadashy/phpstan-friendly-formatter", 3 | "type": "phpstan-extension", 4 | "description": "Simple error formatter for PHPStan that display code frame", 5 | "keywords": ["package", "php", "phpstan", "static analysis"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Kazuki Yamada", 10 | "email": "koukun0120@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.4 || ^8.0", 15 | "php-parallel-lint/php-console-highlighter": "^0.3 || ^0.4 || ^0.5 || ^1.0", 16 | "phpstan/phpstan": "^0.12 || ^1.0 || ^2.0" 17 | }, 18 | "require-dev": { 19 | "friendsofphp/php-cs-fixer": "^3.4.0", 20 | "phpstan/phpstan-phpunit": "^2.0", 21 | "phpunit/phpunit": "^8.5.26 || ^10.0.0" 22 | }, 23 | "minimum-stability": "dev", 24 | "prefer-stable": true, 25 | "autoload": { 26 | "psr-4": { 27 | "Yamadashy\\PhpStanFriendlyFormatter\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Tests\\": "tests/" 33 | } 34 | }, 35 | "config": { 36 | "sort-packages": true, 37 | "preferred-install": "dist" 38 | }, 39 | "extra": { 40 | "phpstan": { 41 | "includes": ["extension.neon"] 42 | } 43 | }, 44 | "scripts": { 45 | "tests": [ 46 | "@cs-fix-dry", 47 | "@analyze", 48 | "@test" 49 | ], 50 | "test": "phpunit", 51 | "analyze": "phpstan analyze -c phpstan.neon.dist --error-format friendly", 52 | "analyze-raw": "phpstan analyze -c phpstan.neon.dist --error-format raw", 53 | "analyze-table": "phpstan analyze -c phpstan.neon.dist --error-format table", 54 | "cs-fix": "php-cs-fixer fix", 55 | "cs-fix-dry": "php-cs-fixer fix --dry-run" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yamadashy/phpstan-friendly-formatter/87d5b381625f1d2e09025876c5a3e81bf7707464/docs/example.png -------------------------------------------------------------------------------- /docs/github-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yamadashy/phpstan-friendly-formatter/87d5b381625f1d2e09025876c5a3e81bf7707464/docs/github-actions.png -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | parametersSchema: 2 | friendly: structure([ 3 | lineBefore: int() 4 | lineAfter: int() 5 | editorUrl: schema(string(), nullable()) 6 | ]) 7 | 8 | parameters: 9 | friendly: 10 | lineBefore: 2 11 | lineAfter: 2 12 | editorUrl: null 13 | 14 | services: 15 | errorFormatter.friendly: 16 | class: Yamadashy\PhpStanFriendlyFormatter\FriendlyErrorFormatter 17 | arguments: 18 | lineBefore: %friendly.lineBefore% 19 | lineAfter: %friendly.lineAfter% 20 | editorUrl: %friendly.editorUrl% 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | tests 17 | 18 | 19 | 20 | 21 | 22 | src 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/CodeHighlight/CodeHighlighter.php: -------------------------------------------------------------------------------- 1 | highlighter = new Highlighter($colors); 28 | } elseif ( 29 | class_exists('\JakubOnderka\PhpConsoleHighlighter\Highlighter') 30 | && class_exists('\JakubOnderka\PhpConsoleColor\ConsoleColor') 31 | ) { 32 | // Support Highlighter and ConsoleColor < 1.0. 33 | $colors = new LegacyConsoleColor(); 34 | $this->highlighter = new LegacyHighlighter($colors); 35 | } else { 36 | // Fallback to non-highlighted output 37 | $this->highlighter = new FallbackHighlighter(); 38 | } 39 | } 40 | 41 | public function highlight(string $fileContent, int $lineNumber, int $lineBefore, int $lineAfter): string 42 | { 43 | /** @phpstan-ignore class.notFound */ 44 | $content = $this->highlighter->getCodeSnippet( 45 | $fileContent, 46 | $lineNumber, 47 | $lineBefore, 48 | $lineAfter 49 | ); 50 | 51 | return rtrim($content, "\n"); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/CodeHighlight/FallbackHighlighter.php: -------------------------------------------------------------------------------- 1 | '.str_pad((string) $i, $lineNumberWidth, ' ', STR_PAD_LEFT).'| '; 24 | $snippet .= ($lineNumber === $i ? ' > ' : ' ').$linePrefix.$currentLine."\n"; 25 | } 26 | 27 | return rtrim($snippet); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Config/FriendlyFormatterConfig.php: -------------------------------------------------------------------------------- 1 | lineBefore = $lineBefore; 30 | $this->lineAfter = $lineAfter; 31 | $this->editorUrl = $editorUrl; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ErrorFormat/ErrorWriter.php: -------------------------------------------------------------------------------- 1 | relativePathHelper = $relativePathHelper; 25 | $this->config = $config; 26 | } 27 | 28 | public function writeFileSpecificErrors(AnalysisResult $analysisResult, Output $output): void 29 | { 30 | $codeHighlighter = new CodeHighlighter(); 31 | $errorsByFile = []; 32 | 33 | foreach ($analysisResult->getFileSpecificErrors() as $error) { 34 | $filePath = $error->getTraitFilePath() ?? $error->getFilePath(); 35 | $relativeFilePath = $this->relativePathHelper->getRelativePath($filePath); 36 | $errorsByFile[$relativeFilePath][] = $error; 37 | } 38 | 39 | foreach ($errorsByFile as $relativeFilePath => $errors) { 40 | $output->writeLineFormatted("❯ {$relativeFilePath}"); 41 | $output->writeLineFormatted('--'.str_repeat('-', mb_strlen($relativeFilePath))); 42 | $output->writeLineFormatted(''); 43 | 44 | foreach ($errors as $error) { 45 | $message = $error->getMessage(); 46 | $tip = $this->getFormattedTip($error); 47 | $errorIdentifier = $error->getIdentifier(); 48 | $filePath = $error->getTraitFilePath() ?? $error->getFilePath(); 49 | $line = $error->getLine(); 50 | $fileContent = null; 51 | 52 | if (file_exists($filePath)) { 53 | $fileContent = (string) file_get_contents($filePath); 54 | } 55 | 56 | if (null === $fileContent) { 57 | $codeSnippet = ' '; 58 | } elseif (null === $line) { 59 | $codeSnippet = ' '; 60 | } else { 61 | $codeSnippet = $codeHighlighter->highlight($fileContent, $line, $this->config->lineBefore, $this->config->lineAfter); 62 | } 63 | 64 | $output->writeLineFormatted(" {$message}"); 65 | 66 | if (null !== $tip) { 67 | $output->writeLineFormatted(" 💡 {$tip}"); 68 | } 69 | 70 | if (null !== $errorIdentifier) { 71 | $output->writeLineFormatted(" 🪪 {$errorIdentifier}"); 72 | } 73 | 74 | if (\is_string($this->config->editorUrl)) { 75 | $output->writeLineFormatted(' ✏️ '.str_replace(['%file%', '%line%'], [$error->getTraitFilePath() ?? $error->getFilePath(), (string) $error->getLine()], $this->config->editorUrl)); 76 | } 77 | 78 | $output->writeLineFormatted($codeSnippet); 79 | $output->writeLineFormatted(''); 80 | } 81 | } 82 | } 83 | 84 | public function writeNotFileSpecificErrors(AnalysisResult $analysisResult, Output $output): void 85 | { 86 | foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { 87 | $output->writeLineFormatted(" {$notFileSpecificError}"); 88 | $output->writeLineFormatted(''); 89 | } 90 | } 91 | 92 | public function writeWarnings(AnalysisResult $analysisResult, Output $output): void 93 | { 94 | foreach ($analysisResult->getWarnings() as $warning) { 95 | $output->writeLineFormatted(" {$warning}"); 96 | $output->writeLineFormatted(''); 97 | } 98 | } 99 | 100 | private function getFormattedTip(Error $error): ?string 101 | { 102 | $tip = $error->getTip(); 103 | 104 | if (null === $tip) { 105 | return null; 106 | } 107 | 108 | return implode("\n ", explode("\n", $tip)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/ErrorFormat/SummaryWriter.php: -------------------------------------------------------------------------------- 1 | '; 11 | 12 | public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output): void 13 | { 14 | /** @var array $errorCounter */ 15 | $errorCounter = []; 16 | 17 | foreach ($analysisResult->getFileSpecificErrors() as $error) { 18 | $identifier = $error->getIdentifier() ?? self::IDENTIFIER_NO_IDENTIFIER; 19 | if (!\array_key_exists($identifier, $errorCounter)) { 20 | $errorCounter[$identifier] = 0; 21 | } 22 | ++$errorCounter[$identifier]; 23 | } 24 | 25 | arsort($errorCounter); 26 | 27 | $output->writeLineFormatted('📊 Error Identifier Summary:'); 28 | $output->writeLineFormatted('────────────────────────────'); 29 | 30 | foreach ($errorCounter as $identifier => $count) { 31 | $output->writeLineFormatted(\sprintf(' %d %s', $count, $identifier)); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/FriendlyErrorFormatter.php: -------------------------------------------------------------------------------- 1 | relativePathHelper = $relativePathHelper; 25 | $this->config = new FriendlyFormatterConfig( 26 | $lineBefore, 27 | $lineAfter, 28 | $editorUrl 29 | ); 30 | } 31 | 32 | /** 33 | * @return int error code 34 | */ 35 | public function formatErrors(AnalysisResult $analysisResult, Output $output): int 36 | { 37 | if (!$analysisResult->hasErrors() && !$analysisResult->hasWarnings()) { 38 | return $this->handleNoErrors($output); 39 | } 40 | 41 | $output->writeLineFormatted(''); 42 | 43 | $errorWriter = new ErrorWriter($this->relativePathHelper, $this->config); 44 | $errorWriter->writeFileSpecificErrors($analysisResult, $output); 45 | $errorWriter->writeNotFileSpecificErrors($analysisResult, $output); 46 | $errorWriter->writeWarnings($analysisResult, $output); 47 | 48 | $summaryWriter = new SummaryWriter(); 49 | $summaryWriter->writeGroupedErrorsSummary($analysisResult, $output); 50 | 51 | $this->writeAnalysisResultMessage($analysisResult, $output); 52 | 53 | return 1; 54 | } 55 | 56 | private function handleNoErrors(Output $output): int 57 | { 58 | $output->getStyle()->success('No errors'); 59 | 60 | return 0; 61 | } 62 | 63 | private function writeAnalysisResultMessage(AnalysisResult $analysisResult, Output $output): void 64 | { 65 | $warningsCount = \count($analysisResult->getWarnings()); 66 | $finalMessage = \sprintf(1 === $analysisResult->getTotalErrorsCount() ? 'Found %d error' : 'Found %d errors', $analysisResult->getTotalErrorsCount()); 67 | 68 | if ($warningsCount > 0) { 69 | $finalMessage .= \sprintf(1 === $warningsCount ? ' and %d warning' : ' and %d warnings', $warningsCount); 70 | } 71 | 72 | if ($analysisResult->getTotalErrorsCount() > 0) { 73 | $output->getStyle()->error($finalMessage); 74 | } else { 75 | $output->getStyle()->warning($finalMessage); 76 | } 77 | } 78 | } 79 | --------------------------------------------------------------------------------