Skip to content

Commit 47c9b01

Browse files
committed
Add ErrorFormatter
0 parents  commit 47c9b01

File tree

6 files changed

+340
-0
lines changed

6 files changed

+340
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/vendor/

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 TicketSwap
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.

composer.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "ticketswap/phpstan-error-formatter",
3+
"type": "phpstan-extension",
4+
"require": {
5+
"phpstan/phpstan": "^1.11",
6+
"php": "^8.3"
7+
},
8+
"license": "MIT",
9+
"autoload": {
10+
"psr-4": {
11+
"Ticketswap\\PhpstanErrorFormatter\\": "src/"
12+
}
13+
}
14+
}

composer.lock

+79
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension.neon

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
services:
2+
errorFormatter.ticketswap:
3+
class: TicketSwap\PHPstanErrorFormatter\TicketSwapErrorFormatter
4+
arguments:
5+
relativePathHelper: '@simpleRelativePathHelper'
6+
editorUrl: '%editorUrl%'

src/ErrorFormatter.php

+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TicketSwap\PHPstanErrorFormatter;
6+
7+
use Override;
8+
use PHPStan\Command\AnalysisResult;
9+
use PHPStan\Command\ErrorFormatter\CiDetectedErrorFormatter;
10+
use PHPStan\Command\ErrorFormatter\ErrorFormatter;
11+
use PHPStan\Command\Output;
12+
use PHPStan\File\RelativePathHelper;
13+
14+
final readonly class TicketSwapErrorFormatter implements ErrorFormatter
15+
{
16+
private const string FORMAT = "{message}\n{links}";
17+
private const string LINK_FORMAT_DEFAULT = "↳ <href={editorUrl}>{shortPath}:{line}</>\n";
18+
private const string LINK_FORMAT_GITHUB_ACTIONS = "↳ {relativePath}:{line}\n";
19+
private const string LINK_FORMAT_WARP = "↳ {relativePath}:{line}\n";
20+
private const string LINK_FORMAT_PHPSTORM = "↳ file://{absolutePath}:{line}\n";
21+
22+
private string $linkFormat;
23+
24+
public function __construct(
25+
private RelativePathHelper $relativePathHelper,
26+
private CiDetectedErrorFormatter $ciDetectedErrorFormatter,
27+
private ?string $editorUrl,
28+
) {
29+
$this->linkFormat = self::getLinkFormatFromEnv();
30+
}
31+
32+
public static function getLinkFormatFromEnv() : string
33+
{
34+
return match (true) {
35+
getenv('GITHUB_ACTIONS') !== false => self::LINK_FORMAT_GITHUB_ACTIONS,
36+
getenv('TERMINAL_EMULATOR') === 'JetBrains-JediTerm' => self::LINK_FORMAT_PHPSTORM,
37+
getenv('TERM_PROGRAM') === 'WarpTerminal' => self::LINK_FORMAT_WARP,
38+
default => self::LINK_FORMAT_DEFAULT,
39+
};
40+
}
41+
42+
#[Override]
43+
public function formatErrors(AnalysisResult $analysisResult, Output $output) : int
44+
{
45+
if (! $analysisResult->hasErrors()) {
46+
$output->writeLineFormatted('<fg=green;options=bold>No errors</>');
47+
$output->writeLineFormatted('');
48+
49+
return 0;
50+
}
51+
52+
foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) {
53+
$output->writeLineFormatted(
54+
sprintf(
55+
'<unknown location> %s',
56+
$notFileSpecificError,
57+
)
58+
);
59+
}
60+
61+
$projectConfigFile = 'phpstan.php';
62+
if ($analysisResult->getProjectConfigFile() !== null) {
63+
$projectConfigFile = $this->relativePathHelper->getRelativePath($analysisResult->getProjectConfigFile());
64+
}
65+
66+
foreach ($analysisResult->getFileSpecificErrors() as $error) {
67+
$output->writeLineFormatted(
68+
strtr(
69+
self::FORMAT,
70+
[
71+
'{message}' => self::highlight(
72+
$error->getMessage(),
73+
$error->getTip() !== null ? str_replace(
74+
'%configurationFile%',
75+
$projectConfigFile,
76+
$error->getTip()
77+
) : null,
78+
$error->getIdentifier(),
79+
),
80+
'{identifier}' => $error->getIdentifier(),
81+
'{links}' => implode([
82+
$this::link(
83+
$this->linkFormat,
84+
(int) $error->getLine(),
85+
$error->getFilePath(),
86+
$this->relativePathHelper->getRelativePath($error->getFilePath()),
87+
$this->editorUrl ?? '',
88+
),
89+
$error->getTraitFilePath() !== null ? $this::link(
90+
$this->linkFormat,
91+
(int) $error->getLine(),
92+
$error->getTraitFilePath(),
93+
$this->relativePathHelper->getRelativePath($error->getTraitFilePath()),
94+
$this->editorUrl ?? '',
95+
) : '',
96+
]),
97+
],
98+
),
99+
);
100+
}
101+
102+
$output->writeLineFormatted(
103+
sprintf(
104+
'<bg=red;options=bold>Found %d error%s</>',
105+
$analysisResult->getTotalErrorsCount(),
106+
$analysisResult->getTotalErrorsCount() === 1 ? '' : 's',
107+
)
108+
);
109+
$output->writeLineFormatted('');
110+
111+
$this->ciDetectedErrorFormatter->formatErrors($analysisResult, $output);
112+
113+
return 1;
114+
}
115+
116+
public static function link(
117+
string $format,
118+
int $line,
119+
string $absolutePath,
120+
string $relativePath,
121+
string $editorUrl) : string
122+
{
123+
return strtr(
124+
$format,
125+
[
126+
'{absolutePath}' => $absolutePath,
127+
'{editorUrl}' => str_replace(
128+
['%file%', '%line%'],
129+
[$absolutePath, $line],
130+
$editorUrl,
131+
),
132+
'{relativePath}' => $relativePath,
133+
'{shortPath}' => self::trimPath($relativePath),
134+
'{line}' => $line,
135+
],
136+
);
137+
}
138+
139+
private static function trimPath(string $path) : string
140+
{
141+
$parts = explode(DIRECTORY_SEPARATOR, $path);
142+
if (count($parts) < 6) {
143+
return $path;
144+
}
145+
146+
return implode(
147+
DIRECTORY_SEPARATOR,
148+
[
149+
...array_slice($parts, 0, 3),
150+
'...',
151+
...array_slice($parts, -2),
152+
],
153+
);
154+
}
155+
156+
public static function highlight(string $message, ?string $tip, ?string $identifier) : string
157+
{
158+
if (str_starts_with($message, 'Ignored error pattern')) {
159+
return $message;
160+
}
161+
162+
// Remove escaped wildcard that breaks coloring
163+
$message = str_replace('\*', '*', $message);
164+
165+
// Full Qualified Class Names
166+
$message = (string) preg_replace(
167+
"/([A-Z0-9]{1}[A-Za-z0-9_\-]+[\\\]+[A-Z0-9]{1}[A-Za-z0-9_\-\\\]+)/",
168+
'<fg=yellow>$1</>',
169+
$message,
170+
);
171+
172+
// Quoted strings
173+
$message = (string) preg_replace(
174+
"/(?<=[\"'])([A-Za-z0-9_\-\\\]+)(?=[\"'])/",
175+
'<fg=yellow>$1</>',
176+
$message,
177+
);
178+
179+
// Variable
180+
$message = (string) preg_replace(
181+
"/(?<=[:]{2}|[\s\"\(])([.]{3})?(\\$[A-Za-z0-9_\\-]+)(?=[\s|\"|\)]|$)/",
182+
'<fg=green>$1$2</>',
183+
$message,
184+
);
185+
186+
// Method
187+
$message = (string) preg_replace(
188+
'/(?<=[:]{2}|[\s])(\w+\(\))/',
189+
'<fg=blue>$1</>',
190+
$message,
191+
);
192+
193+
// Function
194+
$message = (string) preg_replace(
195+
'/(?<=function\s)(\w+)(?=\s)/',
196+
'<fg=blue>$1</>',
197+
$message,
198+
);
199+
200+
// Types
201+
$message = (string) preg_replace(
202+
'/(?<=[\s\|\(><])(null|true|false|int|float|bool|([-\w]+-)?string|array|object|mixed|resource|iterable|void|callable)(?=[:]{2}|[\.\s\|><,\(\)\{\}]+)/',
203+
'<fg=magenta>$1</>',
204+
$message,
205+
);
206+
207+
if ($tip !== null) {
208+
foreach (explode("\n", $tip) as $line) {
209+
$message .= "\n💡 <fg=blue>" . ltrim($line, '') . '</>';
210+
}
211+
}
212+
213+
if ($identifier !== null) {
214+
$message .= "\n🔖 <fg=blue>" . $identifier . '</>';
215+
}
216+
217+
return $message;
218+
}
219+
}

0 commit comments

Comments
 (0)