Skip to content

Commit 916d540

Browse files
MaximilianKresseondrejmirtes
authored andcommitted
Added Teamcity Error Formatter
1 parent 51c0897 commit 916d540

4 files changed

Lines changed: 231 additions & 0 deletions

File tree

conf/config.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,3 +1252,8 @@ services:
12521252
class: PHPStan\Command\ErrorFormatter\GithubErrorFormatter
12531253
arguments:
12541254
relativePathHelper: @simpleRelativePathHelper
1255+
1256+
errorFormatter.teamcity:
1257+
class: PHPStan\Command\ErrorFormatter\TeamcityErrorFormatter
1258+
arguments:
1259+
relativePathHelper: @simpleRelativePathHelper

src/Command/AnalyseCommand.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
145145
$ci = $ciDetector->detect();
146146
if ($ci->getCiName() === CiDetector::CI_GITHUB_ACTIONS) {
147147
$errorFormat = 'github';
148+
} elseif ($ci->getCiName() === CiDetector::CI_TEAMCITY) {
149+
$errorFormat = 'teamcity';
148150
}
149151
} catch (\OndraM\CiDetector\Exception\CiNotDetectedException $e) {
150152
// pass
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Command\ErrorFormatter;
4+
5+
use PHPStan\Command\AnalysisResult;
6+
use PHPStan\Command\Output;
7+
use PHPStan\File\RelativePathHelper;
8+
9+
/**
10+
* @see https://www.jetbrains.com/help/teamcity/build-script-interaction-with-teamcity.html#Reporting+Inspections
11+
*/
12+
class TeamcityErrorFormatter implements ErrorFormatter
13+
{
14+
15+
private RelativePathHelper $relativePathHelper;
16+
17+
public function __construct(RelativePathHelper $relativePathHelper)
18+
{
19+
$this->relativePathHelper = $relativePathHelper;
20+
}
21+
22+
public function formatErrors(AnalysisResult $analysisResult, Output $output): int
23+
{
24+
$result = '';
25+
$fileSpecificErrors = $analysisResult->getFileSpecificErrors();
26+
$notFileSpecificErrors = $analysisResult->getNotFileSpecificErrors();
27+
$warnings = $analysisResult->getWarnings();
28+
29+
if (count($fileSpecificErrors) === 0 && count($notFileSpecificErrors) === 0 && count($warnings) === 0) {
30+
return 0;
31+
}
32+
33+
$result .= $this->createTeamcityLine('inspectionType', [
34+
'id' => 'phpstan',
35+
'name' => 'phpstan',
36+
'category' => 'phpstan',
37+
'description' => 'phpstan Inspection',
38+
]);
39+
40+
foreach ($fileSpecificErrors as $fileSpecificError) {
41+
$result .= $this->createTeamcityLine('inspection', [
42+
'typeId' => 'phpstan',
43+
'message' => $fileSpecificError->getMessage(),
44+
'file' => $this->relativePathHelper->getRelativePath($fileSpecificError->getFile()),
45+
'line' => $fileSpecificError->getLine(),
46+
// additional attributes
47+
'SEVERITY' => 'ERROR',
48+
'ignorable' => $fileSpecificError->canBeIgnored(),
49+
'tip' => $fileSpecificError->getTip(),
50+
]);
51+
}
52+
53+
foreach ($notFileSpecificErrors as $notFileSpecificError) {
54+
$result .= $this->createTeamcityLine('inspection', [
55+
'typeId' => 'phpstan',
56+
'message' => $notFileSpecificError,
57+
// the file is required
58+
'file' => './',
59+
'SEVERITY' => 'ERROR',
60+
]);
61+
}
62+
63+
foreach ($warnings as $warning) {
64+
$result .= $this->createTeamcityLine('inspection', [
65+
'typeId' => 'phpstan',
66+
'message' => $warning,
67+
// the file is required
68+
'file' => './',
69+
'SEVERITY' => 'WARNING',
70+
]);
71+
}
72+
73+
$output->writeRaw($result);
74+
75+
return $analysisResult->hasErrors() ? 1 : 0;
76+
}
77+
78+
/**
79+
* Creates a Teamcity report line
80+
*
81+
* @param string $messageName The message name
82+
* @param mixed[] $keyValuePairs The key=>value pairs
83+
* @return string The Teamcity report line
84+
*/
85+
private function createTeamcityLine(string $messageName, array $keyValuePairs): string
86+
{
87+
$string = '##teamcity[' . $messageName;
88+
foreach ($keyValuePairs as $key => $value) {
89+
if (is_string($value)) {
90+
$value = $this->escape($value);
91+
}
92+
$string .= ' ' . $key . '=\'' . $value . '\'';
93+
}
94+
return $string . ']' . PHP_EOL;
95+
}
96+
97+
/**
98+
* Escapes the given string for Teamcity output
99+
*
100+
* @param string $string The string to escape
101+
* @return string The escaped string
102+
*/
103+
private function escape(string $string): string
104+
{
105+
$replacements = [
106+
'~\n~' => '|n',
107+
'~\r~' => '|r',
108+
'~([\'\|\[\]])~' => '|$1',
109+
];
110+
return (string) preg_replace(array_keys($replacements), array_values($replacements), $string);
111+
}
112+
113+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Command\ErrorFormatter;
4+
5+
use PHPStan\File\FuzzyRelativePathHelper;
6+
use PHPStan\Testing\ErrorFormatterTestCase;
7+
8+
class TeamcityErrorFormatterTest extends ErrorFormatterTestCase
9+
{
10+
11+
public function dataFormatterOutputProvider(): iterable
12+
{
13+
yield [
14+
'No errors',
15+
0,
16+
0,
17+
0,
18+
'',
19+
];
20+
21+
yield [
22+
'One file error',
23+
1,
24+
1,
25+
0,
26+
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
27+
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'4\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
28+
',
29+
];
30+
31+
yield [
32+
'One generic error',
33+
1,
34+
0,
35+
1,
36+
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
37+
##teamcity[inspection typeId=\'phpstan\' message=\'first generic error\' file=\'./\' SEVERITY=\'ERROR\']
38+
',
39+
];
40+
41+
yield [
42+
'Multiple file errors',
43+
1,
44+
4,
45+
0,
46+
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
47+
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'2\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
48+
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'4\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
49+
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'foo.php\' line=\'1\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
50+
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'foo.php\' line=\'5\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
51+
',
52+
];
53+
54+
yield [
55+
'Multiple generic errors',
56+
1,
57+
0,
58+
2,
59+
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
60+
##teamcity[inspection typeId=\'phpstan\' message=\'first generic error\' file=\'./\' SEVERITY=\'ERROR\']
61+
##teamcity[inspection typeId=\'phpstan\' message=\'second generic error\' file=\'./\' SEVERITY=\'ERROR\']
62+
',
63+
];
64+
65+
yield [
66+
'Multiple file, multiple generic errors',
67+
1,
68+
4,
69+
2,
70+
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
71+
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'2\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
72+
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'4\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
73+
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'foo.php\' line=\'1\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
74+
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'foo.php\' line=\'5\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
75+
##teamcity[inspection typeId=\'phpstan\' message=\'first generic error\' file=\'./\' SEVERITY=\'ERROR\']
76+
##teamcity[inspection typeId=\'phpstan\' message=\'second generic error\' file=\'./\' SEVERITY=\'ERROR\']
77+
',
78+
];
79+
}
80+
81+
/**
82+
* @dataProvider dataFormatterOutputProvider
83+
*
84+
* @param string $message
85+
* @param int $exitCode
86+
* @param int $numFileErrors
87+
* @param int $numGenericErrors
88+
* @param string $expected
89+
*/
90+
public function testFormatErrors(
91+
string $message,
92+
int $exitCode,
93+
int $numFileErrors,
94+
int $numGenericErrors,
95+
string $expected
96+
): void
97+
{
98+
$relativePathHelper = new FuzzyRelativePathHelper(self::DIRECTORY_PATH, [], '/');
99+
$formatter = new TeamcityErrorFormatter(
100+
$relativePathHelper
101+
);
102+
103+
$this->assertSame($exitCode, $formatter->formatErrors(
104+
$this->getAnalysisResult($numFileErrors, $numGenericErrors),
105+
$this->getOutput()
106+
), sprintf('%s: response code do not match', $message));
107+
108+
$this->assertEquals($expected, $this->getOutputContent(), sprintf('%s: output do not match', $message));
109+
}
110+
111+
}

0 commit comments

Comments
 (0)