1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Command\ErrorFormatter;
4:
5: use PHPStan\Analyser\Error;
6: use PHPStan\Command\AnalyseCommand;
7: use PHPStan\Command\AnalysisResult;
8: use PHPStan\Command\CommandHelper;
9: use PHPStan\Command\Output;
10: use PHPStan\DependencyInjection\AutowiredParameter;
11: use PHPStan\DependencyInjection\AutowiredService;
12: use PHPStan\File\RelativePathHelper;
13: use PHPStan\File\SimpleRelativePathHelper;
14: use Symfony\Component\Console\Formatter\OutputFormatter;
15: use function array_map;
16: use function array_slice;
17: use function count;
18: use function explode;
19: use function getenv;
20: use function implode;
21: use function in_array;
22: use function is_string;
23: use function ltrim;
24: use function rtrim;
25: use function sprintf;
26: use function str_contains;
27: use function str_replace;
28:
29: #[AutowiredService(name: 'errorFormatter.table')]
30: final class TableErrorFormatter implements ErrorFormatter
31: {
32:
33: public const ERRORS_LIMIT = 1000;
34: private const FORCE_SHOW_ALL_ERRORS = 'PHPSTAN_TABLE_ERROR_FORMATTER_FORCE_SHOW_ALL_ERRORS';
35:
36: public function __construct(
37: private RelativePathHelper $relativePathHelper,
38: #[AutowiredParameter(ref: '@simpleRelativePathHelper')]
39: private SimpleRelativePathHelper $simpleRelativePathHelper,
40: private CiDetectedErrorFormatter $ciDetectedErrorFormatter,
41: #[AutowiredParameter(ref: '%tipsOfTheDay%')]
42: private bool $showTipsOfTheDay,
43: #[AutowiredParameter]
44: private ?string $editorUrl,
45: #[AutowiredParameter]
46: private ?string $editorUrlTitle,
47: #[AutowiredParameter]
48: private string $usedLevel,
49: private ?int $errorsBudget = null,
50: )
51: {
52: if ($this->errorsBudget !== null) {
53: return;
54: }
55:
56: $forceShowAll = getenv(self::FORCE_SHOW_ALL_ERRORS);
57: if (!in_array($forceShowAll, [false, '0'], true)) {
58: return;
59: }
60:
61: $this->errorsBudget = self::ERRORS_LIMIT;
62: }
63:
64: /** @api */
65: public function formatErrors(
66: AnalysisResult $analysisResult,
67: Output $output,
68: ): int
69: {
70: $this->ciDetectedErrorFormatter->formatErrors($analysisResult, $output);
71: $projectConfigFile = 'phpstan.neon';
72: if ($analysisResult->getProjectConfigFile() !== null) {
73: $projectConfigFile = $this->relativePathHelper->getRelativePath($analysisResult->getProjectConfigFile());
74: }
75:
76: $style = $output->getStyle();
77:
78: if (!$analysisResult->hasErrors() && !$analysisResult->hasWarnings()) {
79: $style->success('No errors');
80:
81: if ($this->showTipsOfTheDay) {
82: if ($analysisResult->isDefaultLevelUsed()) {
83: $output->writeLineFormatted('💡 Tip of the Day:');
84: $output->writeLineFormatted(sprintf(
85: "PHPStan is performing only the most basic checks.\nYou can pass a higher rule level through the <fg=cyan>--%s</> option\n(the default and current level is %d) to analyse code more thoroughly.",
86: AnalyseCommand::OPTION_LEVEL,
87: (int) AnalyseCommand::DEFAULT_LEVEL,
88: ));
89: $output->writeLineFormatted('');
90: }
91: }
92:
93: return 0;
94: }
95:
96: /** @var array<string, Error[]> $fileErrors */
97: $fileErrors = [];
98: foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) {
99: if (!isset($fileErrors[$fileSpecificError->getFile()])) {
100: $fileErrors[$fileSpecificError->getFile()] = [];
101: }
102:
103: $fileErrors[$fileSpecificError->getFile()][] = $fileSpecificError;
104: }
105:
106: $errorsBudget = $this->errorsBudget;
107: $printedErrors = 0;
108: foreach ($fileErrors as $file => $errors) {
109: $rows = [];
110: foreach ($errors as $error) {
111: $message = $error->getMessage();
112: $filePath = $error->getTraitFilePath() ?? $error->getFilePath();
113: if (
114: $error->getIdentifier() !== null
115: && in_array($error->getIdentifier(), ['phpstan.type', 'phpstan.nativeType', 'phpstan.variable', 'phpstan.dumpType', 'phpstan.unknownExpectation'], true)
116: ) {
117: $message = '<fg=red>' . OutputFormatter::escape($message) . '</>';
118: }
119: if ($error->getIdentifier() !== null) {
120: $message .= "\n";
121: $message .= '🪪 ' . $error->getIdentifier();
122: if (!$error->canBeIgnored()) {
123: $message .= ' <fg=red>(non-ignorable)</>';
124: }
125: }
126: if ($error->getTip() !== null) {
127: $tip = $error->getTip();
128: $tip = str_replace('%configurationFile%', $projectConfigFile, $tip);
129:
130: $message .= "\n";
131: if (str_contains($tip, "\n")) {
132: $lines = explode("\n", $tip);
133: foreach ($lines as $line) {
134: $message .= '💡 ' . ltrim($line, ' •') . "\n";
135: }
136: $message = rtrim($message, "\n");
137: } else {
138: $message .= '💡 ' . $tip;
139: }
140: }
141:
142: if (getenv('TERMINAL_EMULATOR') === 'JetBrains-JediTerm') {
143: $title = $this->simpleRelativePathHelper->getRelativePath($filePath);
144: $message .= sprintf("\nat %s:%d", $title, $error->getLine() ?? 0);
145:
146: } elseif (is_string($this->editorUrl)) {
147: $url = str_replace(
148: ['%file%', '%relFile%', '%line%'],
149: [$filePath, $this->simpleRelativePathHelper->getRelativePath($filePath), (string) $error->getLine()],
150: $this->editorUrl,
151: );
152:
153: if (is_string($this->editorUrlTitle)) {
154: $title = str_replace(
155: ['%file%', '%relFile%', '%line%'],
156: [$filePath, $this->simpleRelativePathHelper->getRelativePath($filePath), (string) $error->getLine()],
157: $this->editorUrlTitle,
158: );
159: } else {
160: $title = $this->relativePathHelper->getRelativePath($filePath);
161: }
162:
163: $message .= "\n✏️ <href=" . OutputFormatter::escape($url) . '>' . $title . '</>';
164: }
165:
166: $rows[] = [
167: $this->formatLineNumber($error->getLine()),
168: $message,
169: ];
170: }
171:
172: $printedErrors += count($rows);
173: if ($errorsBudget !== null && $printedErrors > $errorsBudget) {
174: $rows = array_slice($rows, 0, $errorsBudget - ($printedErrors - count($rows)));
175:
176: $style->table(['Line', $this->relativePathHelper->getRelativePath($file)], $rows);
177: break;
178: }
179:
180: $style->table(['Line', $this->relativePathHelper->getRelativePath($file)], $rows);
181: }
182:
183: if (count($analysisResult->getNotFileSpecificErrors()) > 0) {
184: $style->table(['', 'Error'], array_map(static fn (string $error): array => ['', OutputFormatter::escape($error)], $analysisResult->getNotFileSpecificErrors()));
185: }
186:
187: $warningsCount = count($analysisResult->getWarnings());
188: if ($warningsCount > 0) {
189: $style->table(['', 'Warning'], array_map(static fn (string $warning): array => ['', OutputFormatter::escape($warning)], $analysisResult->getWarnings()));
190: }
191:
192: if ($errorsBudget !== null && $printedErrors > $errorsBudget) {
193: $style->error(sprintf('Found %s+ errors', $errorsBudget));
194:
195: $note = [];
196: $note[] = sprintf('Result is limited to the first %d errors', $errorsBudget);
197: if ($this->usedLevel !== CommandHelper::DEFAULT_LEVEL) {
198: $note[] = '- Consider lowering the PHPStan level';
199: }
200: $note[] = sprintf('- Pass %s=1 environment variable to show all errors', self::FORCE_SHOW_ALL_ERRORS);
201: $note[] = '- Consider using PHPStan Pro for more comfortable error browsing';
202: $note[] = ' Learn more: https://phpstan.com';
203: $style->note(implode("\n", $note));
204: } else {
205: $finalMessage = sprintf($analysisResult->getTotalErrorsCount() === 1 ? 'Found %d error' : 'Found %d errors', $analysisResult->getTotalErrorsCount());
206: if ($warningsCount > 0) {
207: $finalMessage .= sprintf($warningsCount === 1 ? ' and %d warning' : ' and %d warnings', $warningsCount);
208: }
209:
210: if ($analysisResult->getTotalErrorsCount() > 0) {
211: $style->error($finalMessage);
212: } else {
213: $style->warning($finalMessage);
214: }
215: }
216:
217: return $analysisResult->getTotalErrorsCount() > 0 ? 1 : 0;
218: }
219:
220: private function formatLineNumber(?int $lineNumber): string
221: {
222: if ($lineNumber === null) {
223: return '';
224: }
225:
226: $isRunningInVSCodeTerminal = getenv('TERM_PROGRAM') === 'vscode';
227: if ($isRunningInVSCodeTerminal) {
228: return ':' . $lineNumber;
229: }
230:
231: return (string) $lineNumber;
232: }
233:
234: }
235: