Skip to content
5 changes: 5 additions & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1252,3 +1252,8 @@ services:
class: PHPStan\Command\ErrorFormatter\GithubErrorFormatter
arguments:
relativePathHelper: @simpleRelativePathHelper

errorFormatter.teamcity:
class: PHPStan\Command\ErrorFormatter\TeamcityErrorFormatter
arguments:
relativePathHelper: @simpleRelativePathHelper
2 changes: 2 additions & 0 deletions src/Command/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$ci = $ciDetector->detect();
if ($ci->getCiName() === CiDetector::CI_GITHUB_ACTIONS) {
$errorFormat = 'github';
} elseif ($ci->getCiName() === CiDetector::CI_TEAMCITY) {
$errorFormat = 'teamcity';
}
} catch (\OndraM\CiDetector\Exception\CiNotDetectedException $e) {
// pass
Expand Down
113 changes: 113 additions & 0 deletions src/Command/ErrorFormatter/TeamcityErrorFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php declare(strict_types = 1);

namespace PHPStan\Command\ErrorFormatter;

use PHPStan\Command\AnalysisResult;
use PHPStan\Command\Output;
use PHPStan\File\RelativePathHelper;

/**
* @see https://www.jetbrains.com/help/teamcity/build-script-interaction-with-teamcity.html#Reporting+Inspections
*/
class TeamcityErrorFormatter implements ErrorFormatter
{

private RelativePathHelper $relativePathHelper;

public function __construct(RelativePathHelper $relativePathHelper)
{
$this->relativePathHelper = $relativePathHelper;
}

public function formatErrors(AnalysisResult $analysisResult, Output $output): int
{
$result = '';
$fileSpecificErrors = $analysisResult->getFileSpecificErrors();
$notFileSpecificErrors = $analysisResult->getNotFileSpecificErrors();
$warnings = $analysisResult->getWarnings();

if (count($fileSpecificErrors) === 0 && count($notFileSpecificErrors) === 0 && count($warnings) === 0) {
return 0;
}

$result .= $this->createTeamcityLine('inspectionType', [
'id' => 'phpstan',
'name' => 'phpstan',
'category' => 'phpstan',
'description' => 'phpstan Inspection',
]);

foreach ($fileSpecificErrors as $fileSpecificError) {
$result .= $this->createTeamcityLine('inspection', [
'typeId' => 'phpstan',
'message' => $fileSpecificError->getMessage(),
'file' => $this->relativePathHelper->getRelativePath($fileSpecificError->getFile()),
'line' => $fileSpecificError->getLine(),
// additional attributes
'SEVERITY' => 'ERROR',
'ignorable' => $fileSpecificError->canBeIgnored(),
'tip' => $fileSpecificError->getTip(),
]);
}

foreach ($notFileSpecificErrors as $notFileSpecificError) {
$result .= $this->createTeamcityLine('inspection', [
'typeId' => 'phpstan',
'message' => $notFileSpecificError,
// the file is required
'file' => './',
'SEVERITY' => 'ERROR',
]);
}

foreach ($warnings as $warning) {
$result .= $this->createTeamcityLine('inspection', [
'typeId' => 'phpstan',
'message' => $warning,
// the file is required
'file' => './',
'SEVERITY' => 'WARNING',
]);
}

$output->writeRaw($result);

return $analysisResult->hasErrors() ? 1 : 0;
}

/**
* Creates a Teamcity report line
*
* @param string $messageName The message name
* @param mixed[] $keyValuePairs The key=>value pairs
* @return string The Teamcity report line
*/
private function createTeamcityLine(string $messageName, array $keyValuePairs): string
{
$string = '##teamcity[' . $messageName;
foreach ($keyValuePairs as $key => $value) {
if (is_string($value)) {
$value = $this->escape($value);
}
$string .= ' ' . $key . '=\'' . $value . '\'';
}
return $string . ']' . PHP_EOL;
}

/**
* Escapes the given string for Teamcity output
*
* @param string $string The string to escape
* @return string The escaped string
*/
private function escape(string $string): string
{
$replacements = [
'~\n~' => '|n',
'~\r~' => '|r',
'~([\'\|\[\]])~' => '|$1',
];
return (string) preg_replace(array_keys($replacements), array_values($replacements), $string);
}

}
111 changes: 111 additions & 0 deletions tests/PHPStan/Command/ErrorFormatter/TeamcityErrorFormatterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php declare(strict_types = 1);

namespace PHPStan\Command\ErrorFormatter;

use PHPStan\File\FuzzyRelativePathHelper;
use PHPStan\Testing\ErrorFormatterTestCase;

class TeamcityErrorFormatterTest extends ErrorFormatterTestCase
{

public function dataFormatterOutputProvider(): iterable
{
yield [
'No errors',
0,
0,
0,
'',
];

yield [
'One file error',
1,
1,
0,
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'4\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
',
];

yield [
'One generic error',
1,
0,
1,
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
##teamcity[inspection typeId=\'phpstan\' message=\'first generic error\' file=\'./\' SEVERITY=\'ERROR\']
',
];

yield [
'Multiple file errors',
1,
4,
0,
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'2\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'4\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'foo.php\' line=\'1\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'foo.php\' line=\'5\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
',
];

yield [
'Multiple generic errors',
1,
0,
2,
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
##teamcity[inspection typeId=\'phpstan\' message=\'first generic error\' file=\'./\' SEVERITY=\'ERROR\']
##teamcity[inspection typeId=\'phpstan\' message=\'second generic error\' file=\'./\' SEVERITY=\'ERROR\']
',
];

yield [
'Multiple file, multiple generic errors',
1,
4,
2,
'##teamcity[inspectionType id=\'phpstan\' name=\'phpstan\' category=\'phpstan\' description=\'phpstan Inspection\']
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'2\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'folder with unicode 😃/file name with "spaces" and unicode 😃.php\' line=\'4\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'Foo\' file=\'foo.php\' line=\'1\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'Bar\' file=\'foo.php\' line=\'5\' SEVERITY=\'ERROR\' ignorable=\'1\' tip=\'\']
##teamcity[inspection typeId=\'phpstan\' message=\'first generic error\' file=\'./\' SEVERITY=\'ERROR\']
##teamcity[inspection typeId=\'phpstan\' message=\'second generic error\' file=\'./\' SEVERITY=\'ERROR\']
',
];
}

/**
* @dataProvider dataFormatterOutputProvider
*
* @param string $message
* @param int $exitCode
* @param int $numFileErrors
* @param int $numGenericErrors
* @param string $expected
*/
public function testFormatErrors(
string $message,
int $exitCode,
int $numFileErrors,
int $numGenericErrors,
string $expected
): void
{
$relativePathHelper = new FuzzyRelativePathHelper(self::DIRECTORY_PATH, [], '/');
$formatter = new TeamcityErrorFormatter(
$relativePathHelper
);

$this->assertSame($exitCode, $formatter->formatErrors(
$this->getAnalysisResult($numFileErrors, $numGenericErrors),
$this->getOutput()
), sprintf('%s: response code do not match', $message));

$this->assertEquals($expected, $this->getOutputContent(), sprintf('%s: output do not match', $message));
}

}