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\Output;
9: use PHPStan\File\RelativePathHelper;
10: use PHPStan\File\SimpleRelativePathHelper;
11: use Symfony\Component\Console\Formatter\OutputFormatter;
12: use function array_map;
13: use function count;
14: use function explode;
15: use function getenv;
16: use function in_array;
17: use function is_string;
18: use function ltrim;
19: use function rtrim;
20: use function sprintf;
21: use function str_contains;
22: use function str_replace;
23:
24: final class TableErrorFormatter implements ErrorFormatter
25: {
26:
27: public function __construct(
28: private RelativePathHelper $relativePathHelper,
29: private SimpleRelativePathHelper $simpleRelativePathHelper,
30: private CiDetectedErrorFormatter $ciDetectedErrorFormatter,
31: private bool $showTipsOfTheDay,
32: private ?string $editorUrl,
33: private ?string $editorUrlTitle,
34: )
35: {
36: }
37:
38: /** @api */
39: public function formatErrors(
40: AnalysisResult $analysisResult,
41: Output $output,
42: ): int
43: {
44: $this->ciDetectedErrorFormatter->formatErrors($analysisResult, $output);
45: $projectConfigFile = 'phpstan.neon';
46: if ($analysisResult->getProjectConfigFile() !== null) {
47: $projectConfigFile = $this->relativePathHelper->getRelativePath($analysisResult->getProjectConfigFile());
48: }
49:
50: $style = $output->getStyle();
51:
52: if (!$analysisResult->hasErrors() && !$analysisResult->hasWarnings()) {
53: $style->success('No errors');
54:
55: if ($this->showTipsOfTheDay) {
56: if ($analysisResult->isDefaultLevelUsed()) {
57: $output->writeLineFormatted('šŸ’” Tip of the Day:');
58: $output->writeLineFormatted(sprintf(
59: "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.",
60: AnalyseCommand::OPTION_LEVEL,
61: AnalyseCommand::DEFAULT_LEVEL,
62: ));
63: $output->writeLineFormatted('');
64: }
65: }
66:
67: return 0;
68: }
69:
70: /** @var array<string, Error[]> $fileErrors */
71: $fileErrors = [];
72: foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) {
73: if (!isset($fileErrors[$fileSpecificError->getFile()])) {
74: $fileErrors[$fileSpecificError->getFile()] = [];
75: }
76:
77: $fileErrors[$fileSpecificError->getFile()][] = $fileSpecificError;
78: }
79:
80: $fixableErrorsCount = 0;
81: foreach ($fileErrors as $file => $errors) {
82: $rows = [];
83: foreach ($errors as $error) {
84: $message = $error->getMessage();
85: if ($error->getFixedErrorDiff() !== null) {
86: $message .= ' šŸ”§';
87: $fixableErrorsCount++;
88: }
89: $filePath = $error->getTraitFilePath() ?? $error->getFilePath();
90: if ($error->getIdentifier() !== null && $error->canBeIgnored()) {
91: $message .= "\n";
92: $message .= '🪪 ' . $error->getIdentifier();
93: }
94: if ($error->getTip() !== null) {
95: $tip = $error->getTip();
96: $tip = str_replace('%configurationFile%', $projectConfigFile, $tip);
97:
98: $message .= "\n";
99: if (str_contains($tip, "\n")) {
100: $lines = explode("\n", $tip);
101: foreach ($lines as $line) {
102: $message .= 'šŸ’” ' . ltrim($line, ' •') . "\n";
103: }
104: $message = rtrim($message, "\n");
105: } else {
106: $message .= 'šŸ’” ' . $tip;
107: }
108: }
109: if (is_string($this->editorUrl)) {
110: $url = str_replace(
111: ['%file%', '%relFile%', '%line%'],
112: [$filePath, $this->simpleRelativePathHelper->getRelativePath($filePath), (string) $error->getLine()],
113: $this->editorUrl,
114: );
115:
116: if (is_string($this->editorUrlTitle)) {
117: $title = str_replace(
118: ['%file%', '%relFile%', '%line%'],
119: [$filePath, $this->simpleRelativePathHelper->getRelativePath($filePath), (string) $error->getLine()],
120: $this->editorUrlTitle,
121: );
122: } else {
123: $title = $this->relativePathHelper->getRelativePath($filePath);
124: }
125:
126: $message .= "\nāœļø <href=" . OutputFormatter::escape($url) . '>' . $title . '</>';
127: }
128:
129: if (
130: $error->getIdentifier() !== null
131: && in_array($error->getIdentifier(), ['phpstan.type', 'phpstan.nativeType', 'phpstan.variable', 'phpstan.dumpType', 'phpstan.unknownExpectation'], true)
132: ) {
133: $message = '<fg=red>' . $message . '</>';
134: }
135:
136: $rows[] = [
137: $this->formatLineNumber($error->getLine()),
138: $message,
139: ];
140: }
141:
142: $style->table(['Line', $this->relativePathHelper->getRelativePath($file)], $rows);
143: }
144:
145: if (count($analysisResult->getNotFileSpecificErrors()) > 0) {
146: $style->table(['', 'Error'], array_map(static fn (string $error): array => ['', OutputFormatter::escape($error)], $analysisResult->getNotFileSpecificErrors()));
147: }
148:
149: $warningsCount = count($analysisResult->getWarnings());
150: if ($warningsCount > 0) {
151: $style->table(['', 'Warning'], array_map(static fn (string $warning): array => ['', OutputFormatter::escape($warning)], $analysisResult->getWarnings()));
152: }
153:
154: $finalMessage = sprintf($analysisResult->getTotalErrorsCount() === 1 ? 'Found %d error' : 'Found %d errors', $analysisResult->getTotalErrorsCount());
155: if ($warningsCount > 0) {
156: $finalMessage .= sprintf($warningsCount === 1 ? ' and %d warning' : ' and %d warnings', $warningsCount);
157: }
158:
159: if ($analysisResult->getTotalErrorsCount() > 0) {
160: $style->error($finalMessage);
161: } else {
162: $style->warning($finalMessage);
163: }
164:
165: if ($fixableErrorsCount > 0) {
166: $output->writeLineFormatted(sprintf('šŸ”§ %d %s can be fixed automatically. Run PHPStan again with <fg=cyan>--fix</>.', $fixableErrorsCount, $fixableErrorsCount === 1 ? 'error' : 'errors'));
167: $output->writeLineFormatted('');
168: }
169:
170: return $analysisResult->getTotalErrorsCount() > 0 ? 1 : 0;
171: }
172:
173: private function formatLineNumber(?int $lineNumber): string
174: {
175: if ($lineNumber === null) {
176: return '';
177: }
178:
179: $isRunningInVSCodeTerminal = getenv('TERM_PROGRAM') === 'vscode';
180: if ($isRunningInVSCodeTerminal) {
181: return ':' . $lineNumber;
182: }
183:
184: return (string) $lineNumber;
185: }
186:
187: }
188: