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