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