Skip to content

Commit cf8daa8

Browse files
committed
feat(formatter): add GitLab CI formatter
Signed-off-by: Emilien Escalle <[email protected]>
1 parent 13c01f7 commit cf8daa8

File tree

4 files changed

+283
-2
lines changed

4 files changed

+283
-2
lines changed

src/CssLint/Formatter/FormatterFactory.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use RuntimeException;
88
use CssLint\Formatter\GithubActionsFormatter;
9+
use CssLint\Formatter\GitlabCiFormatter;
910

1011
/**
1112
* Factory to create FormatterManager based on requested names.
@@ -17,7 +18,11 @@ class FormatterFactory
1718

1819
public function __construct()
1920
{
20-
$availableFormatters = [new PlainFormatter(), new GithubActionsFormatter()];
21+
$availableFormatters = [
22+
new PlainFormatter(),
23+
new GithubActionsFormatter(),
24+
new GitlabCiFormatter(),
25+
];
2126
foreach ($availableFormatters as $formatter) {
2227
$this->available[$formatter->getName()] = $formatter;
2328
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CssLint\Formatter;
6+
7+
use CssLint\LintError;
8+
use CssLint\Position;
9+
use RuntimeException;
10+
use Throwable;
11+
12+
enum IssueSeverity: string
13+
{
14+
case CRITICAL = 'critical';
15+
case MAJOR = 'major';
16+
case MINOR = 'minor';
17+
case INFO = 'info';
18+
}
19+
20+
/**
21+
* Formatter for GitLab CI Code Quality report (Code Climate JSON format).
22+
*/
23+
class GitlabCiFormatter implements FormatterInterface
24+
{
25+
/**
26+
* @var array<string> Used to track fingerprints to avoid duplicates.
27+
* This is not strictly necessary for GitLab CI, but helps ensure unique issues.
28+
*/
29+
private $fingerprints = [];
30+
31+
public function getName(): string
32+
{
33+
return 'gitlab-ci';
34+
}
35+
36+
public function startLinting(string $source): void
37+
{
38+
// Initialize fingerprints to track issues
39+
$this->fingerprints = [];
40+
echo "[";
41+
}
42+
43+
public function printFatalError(?string $source, mixed $error): void
44+
{
45+
$checkName = $error instanceof Throwable ? $error::class : 'CssLint';
46+
$message = $error instanceof Throwable ? $error->getMessage() : (string) $error;
47+
48+
$this->printIssue(
49+
$source ?? '',
50+
IssueSeverity::CRITICAL,
51+
$checkName,
52+
$message,
53+
new Position()
54+
);
55+
}
56+
57+
public function printLintError(string $source, LintError $lintError): void
58+
{
59+
$this->printIssue(
60+
$source,
61+
IssueSeverity::MAJOR,
62+
$lintError->getKey()->value,
63+
$lintError->getMessage(),
64+
$lintError->getStart(),
65+
$lintError->getEnd()
66+
);
67+
}
68+
69+
public function endLinting(string $source, bool $isValid): void
70+
{
71+
echo ']';
72+
}
73+
74+
private function printIssue(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): void
75+
{
76+
$this->printCommaIfNeeded();
77+
78+
$fingerprint = $this->generateFingerprint(
79+
$path,
80+
$severity,
81+
$checkName,
82+
$message,
83+
$begin,
84+
$end
85+
);
86+
87+
$issue = [
88+
'description' => $message,
89+
'check_name' => $checkName,
90+
'fingerprint' => $fingerprint,
91+
'severity' => $severity->value,
92+
'location' => [
93+
'path' => $path,
94+
'positions' => [
95+
'begin' => ['line' => $begin->getLine(), 'column' => $begin->getColumn()],
96+
],
97+
],
98+
];
99+
100+
if ($end) {
101+
$issue['location']['positions']['end'] = [
102+
'line' => $end->getLine(),
103+
'column' => $end->getColumn(),
104+
];
105+
}
106+
107+
echo json_encode($issue);
108+
}
109+
110+
private function printCommaIfNeeded(): void
111+
{
112+
if ($this->fingerprints) {
113+
echo ',';
114+
}
115+
}
116+
117+
private function generateFingerprint(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): string
118+
{
119+
$attempts = 0;
120+
while ($attempts < 10) {
121+
122+
$payload = "{$path}:{$severity->value}:{$checkName}:{$message}:{$begin->getLine()}:{$begin->getColumn()}";
123+
if ($end) {
124+
$payload .= ":{$end->getLine()}:{$end->getColumn()}";
125+
}
126+
127+
if ($attempts > 0) {
128+
$uniquid = uniqid('', true);
129+
$payload .= ":{$uniquid}";
130+
}
131+
132+
$fingerprint = md5($payload);
133+
if (!in_array($fingerprint, $this->fingerprints, true)) {
134+
$this->fingerprints[] = $fingerprint;
135+
return $fingerprint;
136+
}
137+
138+
$attempts++;
139+
}
140+
141+
throw new RuntimeException('Failed to generate unique fingerprint after 10 attempts');
142+
}
143+
}

tests/TestSuite/Formatter/GithubActionsFormatterTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function testPrintLintError(): void
5454
);
5555

5656
$formatter = new GithubActionsFormatter();
57-
$this->expectOutputString("::error file=file.css,line=10,col=5::issue found" . PHP_EOL);
57+
$this->expectOutputString("::error file=file.css,line=10,col=5::invalid_at_rule_declaration - issue found" . PHP_EOL);
5858
$formatter->printLintError('file.css', $lintError);
5959
}
6060

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\TestSuite\Formatter;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use CssLint\Formatter\GitlabCiFormatter;
9+
use CssLint\Position;
10+
use CssLint\LintError;
11+
use CssLint\LintErrorKey;
12+
use Exception;
13+
use RuntimeException;
14+
15+
class GitlabCiFormatterTest extends TestCase
16+
{
17+
public function testGetNameReturnsGitlabCi(): void
18+
{
19+
$formatter = new GitlabCiFormatter();
20+
$this->assertSame('gitlab-ci', $formatter->getName());
21+
}
22+
23+
public function testStartAndEndLintingOutputsEmptyArray(): void
24+
{
25+
$formatter = new GitlabCiFormatter();
26+
27+
$this->expectOutputString('[]');
28+
$formatter->startLinting('file.css');
29+
$formatter->endLinting('file.css', false);
30+
}
31+
32+
public function testPrintFatalErrorFormatsIssueCorrectly(): void
33+
{
34+
$formatter = new GitlabCiFormatter();
35+
$error = new Exception('fatal error');
36+
37+
// Prepare expected issue
38+
$path = 'file.css';
39+
$severity = 'critical';
40+
$checkName = get_class($error);
41+
$message = $error->getMessage();
42+
$line = 1;
43+
$column = 1;
44+
$payload = sprintf("%s:%s:%s:%s:%d:%d", $path, $severity, $checkName, $message, $line, $column);
45+
$fingerprint = md5($payload);
46+
$issue = [
47+
'description' => $message,
48+
'check_name' => $checkName,
49+
'fingerprint' => $fingerprint,
50+
'severity' => $severity,
51+
'location' => [
52+
'path' => $path,
53+
'positions' => [
54+
'begin' => ['line' => $line, 'column' => $column],
55+
],
56+
],
57+
];
58+
59+
$expected = '[' . json_encode($issue) . ']';
60+
61+
$this->expectOutputString($expected);
62+
63+
$formatter->startLinting($path);
64+
$formatter->printFatalError($path, $error);
65+
$formatter->endLinting($path, false);
66+
}
67+
68+
public function testPrintLintErrorFormatsIssueCorrectly(): void
69+
{
70+
$formatter = new GitlabCiFormatter();
71+
$path = 'file.css';
72+
$line = 10;
73+
$col = 5;
74+
$key = LintErrorKey::INVALID_AT_RULE_DECLARATION;
75+
$message = 'issue found';
76+
$lintError = new LintError(
77+
key: $key,
78+
message: $message,
79+
start: new Position($line, $col),
80+
end: new Position($line, $col)
81+
);
82+
83+
// Compute payload and fingerprint
84+
$severity = 'major';
85+
$payload = sprintf("%s:%s:%s:%s:%d:%d:%d:%d", $path, $severity, $key->value, $message, $line, $col, $line, $col);
86+
$fingerprint = md5($payload);
87+
88+
$issue = [
89+
'description' => $message,
90+
'check_name' => $key->value,
91+
'fingerprint' => $fingerprint,
92+
'severity' => $severity,
93+
'location' => [
94+
'path' => $path,
95+
'positions' => [
96+
'begin' => ['line' => $line, 'column' => $col],
97+
'end' => ['line' => $line, 'column' => $col],
98+
],
99+
],
100+
];
101+
102+
$expected = '[' . json_encode($issue) . ']';
103+
104+
$this->expectOutputString($expected);
105+
106+
$formatter->startLinting($path);
107+
$formatter->printLintError($path, $lintError);
108+
$formatter->endLinting($path, false);
109+
}
110+
111+
public function testDuplicateIssues(): void
112+
{
113+
$formatter = new GitlabCiFormatter();
114+
$path = 'file.css';
115+
$error = new Exception('dup');
116+
117+
118+
$formatter->startLinting($path);
119+
// Print the same fatal error twice
120+
$formatter->printFatalError($path, $error);
121+
$formatter->printFatalError($path, $error);
122+
$formatter->endLinting($path, false);
123+
124+
$output = $this->getActualOutputForAssertion();
125+
$this->assertJson($output, 'Output is not valid JSON');
126+
$issues = json_decode($output, true);
127+
$this->assertCount(2, $issues);
128+
129+
// Ensure fingerprints are different
130+
$fingerprints = array_map(fn($issue) => $issue['fingerprint'], $issues);
131+
$this->assertCount(count(array_unique($fingerprints)), $fingerprints, 'Duplicate fingerprints found in output');
132+
}
133+
}

0 commit comments

Comments
 (0)