• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keradus / PHP-CS-Fixer / 15295226534

28 May 2025 08:23AM UTC coverage: 94.849% (-0.01%) from 94.859%
15295226534

push

github

keradus
DX: introduce `FCT` class for tokens not present in the lowest supported PHP version (#8706)

Co-authored-by: Dariusz Rumiński <dariusz.ruminski@gmail.com>

186 of 192 new or added lines in 52 files covered. (96.88%)

307 existing lines in 29 files now uncovered.

28099 of 29625 relevant lines covered (94.85%)

45.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.94
/src/Fixer/LanguageConstruct/SingleSpaceAroundConstructFixer.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <fabien@symfony.com>
9
 *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14

15
namespace PhpCsFixer\Fixer\LanguageConstruct;
16

17
use PhpCsFixer\AbstractFixer;
18
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
19
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
20
use PhpCsFixer\FixerConfiguration\AllowedValueSubset;
21
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
22
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
23
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
24
use PhpCsFixer\FixerDefinition\CodeSample;
25
use PhpCsFixer\FixerDefinition\FixerDefinition;
26
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
27
use PhpCsFixer\Preg;
28
use PhpCsFixer\Tokenizer\CT;
29
use PhpCsFixer\Tokenizer\FCT;
30
use PhpCsFixer\Tokenizer\Token;
31
use PhpCsFixer\Tokenizer\Tokens;
32

33
/**
34
 * @author Andreas Möller <am@localheinz.com>
35
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
36
 *
37
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
38
 *
39
 * @phpstan-type _AutogeneratedInputConfiguration array{
40
 *  constructs_contain_a_single_space?: list<'yield_from'>,
41
 *  constructs_followed_by_a_single_space?: list<'abstract'|'as'|'attribute'|'break'|'case'|'catch'|'class'|'clone'|'comment'|'const'|'const_import'|'continue'|'do'|'echo'|'else'|'elseif'|'enum'|'extends'|'final'|'finally'|'for'|'foreach'|'function'|'function_import'|'global'|'goto'|'if'|'implements'|'include'|'include_once'|'instanceof'|'insteadof'|'interface'|'match'|'named_argument'|'namespace'|'new'|'open_tag_with_echo'|'php_doc'|'php_open'|'print'|'private'|'protected'|'public'|'readonly'|'require'|'require_once'|'return'|'static'|'switch'|'throw'|'trait'|'try'|'type_colon'|'use'|'use_lambda'|'use_trait'|'var'|'while'|'yield'|'yield_from'>,
42
 *  constructs_preceded_by_a_single_space?: list<'as'|'else'|'elseif'|'use_lambda'>,
43
 * }
44
 * @phpstan-type _AutogeneratedComputedConfiguration array{
45
 *  constructs_contain_a_single_space: list<'yield_from'>,
46
 *  constructs_followed_by_a_single_space: list<'abstract'|'as'|'attribute'|'break'|'case'|'catch'|'class'|'clone'|'comment'|'const'|'const_import'|'continue'|'do'|'echo'|'else'|'elseif'|'enum'|'extends'|'final'|'finally'|'for'|'foreach'|'function'|'function_import'|'global'|'goto'|'if'|'implements'|'include'|'include_once'|'instanceof'|'insteadof'|'interface'|'match'|'named_argument'|'namespace'|'new'|'open_tag_with_echo'|'php_doc'|'php_open'|'print'|'private'|'protected'|'public'|'readonly'|'require'|'require_once'|'return'|'static'|'switch'|'throw'|'trait'|'try'|'type_colon'|'use'|'use_lambda'|'use_trait'|'var'|'while'|'yield'|'yield_from'>,
47
 *  constructs_preceded_by_a_single_space: list<'as'|'else'|'elseif'|'use_lambda'>,
48
 * }
49
 */
50
final class SingleSpaceAroundConstructFixer extends AbstractFixer implements ConfigurableFixerInterface
51
{
52
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
53
    use ConfigurableFixerTrait;
54

55
    /**
56
     * @var array<string, int>
57
     */
58
    private const TOKEN_MAP_CONTAIN_A_SINGLE_SPACE = [
59
        // for now, only one case - but we are ready to extend it, when we learn about new cases to cover
60
        'yield_from' => T_YIELD_FROM,
61
    ];
62

63
    /**
64
     * @var array<string, int>
65
     */
66
    private const TOKEN_MAP_PRECEDED_BY_A_SINGLE_SPACE = [
67
        'as' => T_AS,
68
        'else' => T_ELSE,
69
        'elseif' => T_ELSEIF,
70
        'use_lambda' => CT::T_USE_LAMBDA,
71
    ];
72

73
    /**
74
     * @var array<string, int>
75
     */
76
    private const TOKEN_MAP_FOLLOWED_BY_A_SINGLE_SPACE = [
77
        'abstract' => T_ABSTRACT,
78
        'as' => T_AS,
79
        'attribute' => CT::T_ATTRIBUTE_CLOSE,
80
        'break' => T_BREAK,
81
        'case' => T_CASE,
82
        'catch' => T_CATCH,
83
        'class' => T_CLASS,
84
        'clone' => T_CLONE,
85
        'comment' => T_COMMENT,
86
        'const' => T_CONST,
87
        'const_import' => CT::T_CONST_IMPORT,
88
        'continue' => T_CONTINUE,
89
        'do' => T_DO,
90
        'echo' => T_ECHO,
91
        'else' => T_ELSE,
92
        'elseif' => T_ELSEIF,
93
        'enum' => FCT::T_ENUM,
94
        'extends' => T_EXTENDS,
95
        'final' => T_FINAL,
96
        'finally' => T_FINALLY,
97
        'for' => T_FOR,
98
        'foreach' => T_FOREACH,
99
        'function' => T_FUNCTION,
100
        'function_import' => CT::T_FUNCTION_IMPORT,
101
        'global' => T_GLOBAL,
102
        'goto' => T_GOTO,
103
        'if' => T_IF,
104
        'implements' => T_IMPLEMENTS,
105
        'include' => T_INCLUDE,
106
        'include_once' => T_INCLUDE_ONCE,
107
        'instanceof' => T_INSTANCEOF,
108
        'insteadof' => T_INSTEADOF,
109
        'interface' => T_INTERFACE,
110
        'match' => FCT::T_MATCH,
111
        'named_argument' => CT::T_NAMED_ARGUMENT_COLON,
112
        'namespace' => T_NAMESPACE,
113
        'new' => T_NEW,
114
        'open_tag_with_echo' => T_OPEN_TAG_WITH_ECHO,
115
        'php_doc' => T_DOC_COMMENT,
116
        'php_open' => T_OPEN_TAG,
117
        'print' => T_PRINT,
118
        'private' => T_PRIVATE,
119
        'protected' => T_PROTECTED,
120
        'public' => T_PUBLIC,
121
        'readonly' => FCT::T_READONLY,
122
        'require' => T_REQUIRE,
123
        'require_once' => T_REQUIRE_ONCE,
124
        'return' => T_RETURN,
125
        'static' => T_STATIC,
126
        'switch' => T_SWITCH,
127
        'throw' => T_THROW,
128
        'trait' => T_TRAIT,
129
        'try' => T_TRY,
130
        'type_colon' => CT::T_TYPE_COLON,
131
        'use' => T_USE,
132
        'use_lambda' => CT::T_USE_LAMBDA,
133
        'use_trait' => CT::T_USE_TRAIT,
134
        'var' => T_VAR,
135
        'while' => T_WHILE,
136
        'yield' => T_YIELD,
137
        'yield_from' => T_YIELD_FROM,
138
    ];
139

140
    /**
141
     * @var array<string, int>
142
     */
143
    private array $fixTokenMapFollowedByASingleSpace = [];
144

145
    /**
146
     * @var array<string, int>
147
     */
148
    private array $fixTokenMapContainASingleSpace = [];
149

150
    /**
151
     * @var array<string, int>
152
     */
153
    private array $fixTokenMapPrecededByASingleSpace = [];
154

155
    public function getDefinition(): FixerDefinitionInterface
156
    {
157
        return new FixerDefinition(
3✔
158
            'Ensures a single space after language constructs.',
3✔
159
            [
3✔
160
                new CodeSample(
3✔
161
                    '<?php
3✔
162

163
throw  new  \Exception();
164
'
3✔
165
                ),
3✔
166
                new CodeSample(
3✔
167
                    '<?php
3✔
168

169
function foo() { yield  from  baz(); }
170
',
3✔
171
                    [
3✔
172
                        'constructs_contain_a_single_space' => [
3✔
173
                            'yield_from',
3✔
174
                        ],
3✔
175
                        'constructs_followed_by_a_single_space' => [
3✔
176
                            'yield_from',
3✔
177
                        ],
3✔
178
                    ]
3✔
179
                ),
3✔
180

181
                new CodeSample(
3✔
182
                    '<?php
3✔
183

184
$foo = function& ()use($bar) {
185
};
186
',
3✔
187
                    [
3✔
188
                        'constructs_preceded_by_a_single_space' => [
3✔
189
                            'use_lambda',
3✔
190
                        ],
3✔
191
                        'constructs_followed_by_a_single_space' => [
3✔
192
                            'use_lambda',
3✔
193
                        ],
3✔
194
                    ]
3✔
195
                ),
3✔
196
                new CodeSample(
3✔
197
                    '<?php
3✔
198

199
echo  "Hello!";
200
',
3✔
201
                    [
3✔
202
                        'constructs_followed_by_a_single_space' => [
3✔
203
                            'echo',
3✔
204
                        ],
3✔
205
                    ]
3✔
206
                ),
3✔
207
                new CodeSample(
3✔
208
                    '<?php
3✔
209

210
yield  from  baz();
211
',
3✔
212
                    [
3✔
213
                        'constructs_followed_by_a_single_space' => [
3✔
214
                            'yield_from',
3✔
215
                        ],
3✔
216
                    ]
3✔
217
                ),
3✔
218
            ]
3✔
219
        );
3✔
220
    }
221

222
    /**
223
     * {@inheritdoc}
224
     *
225
     * Must run before BracesFixer, FunctionDeclarationFixer.
226
     * Must run after ArraySyntaxFixer, ModernizeStrposFixer.
227
     */
228
    public function getPriority(): int
229
    {
230
        return 36;
1✔
231
    }
232

233
    public function isCandidate(Tokens $tokens): bool
234
    {
235
        $tokenKinds = [
400✔
236
            ...array_values($this->fixTokenMapContainASingleSpace),
400✔
237
            ...array_values($this->fixTokenMapPrecededByASingleSpace),
400✔
238
            ...array_values($this->fixTokenMapFollowedByASingleSpace),
400✔
239
        ];
400✔
240

241
        return $tokens->isAnyTokenKindsFound($tokenKinds);
400✔
242
    }
243

244
    protected function configurePostNormalisation(): void
245
    {
246
        $this->fixTokenMapContainASingleSpace = [];
417✔
247

248
        foreach ($this->configuration['constructs_contain_a_single_space'] as $key) {
417✔
249
            $this->fixTokenMapContainASingleSpace[$key] = self::TOKEN_MAP_CONTAIN_A_SINGLE_SPACE[$key];
417✔
250
        }
251

252
        $this->fixTokenMapPrecededByASingleSpace = [];
417✔
253

254
        foreach ($this->configuration['constructs_preceded_by_a_single_space'] as $key) {
417✔
255
            $this->fixTokenMapPrecededByASingleSpace[$key] = self::TOKEN_MAP_PRECEDED_BY_A_SINGLE_SPACE[$key];
417✔
256
        }
257

258
        $this->fixTokenMapFollowedByASingleSpace = [];
417✔
259

260
        foreach ($this->configuration['constructs_followed_by_a_single_space'] as $key) {
417✔
261
            $this->fixTokenMapFollowedByASingleSpace[$key] = self::TOKEN_MAP_FOLLOWED_BY_A_SINGLE_SPACE[$key];
417✔
262
        }
263

264
        if (isset($this->fixTokenMapFollowedByASingleSpace['public'])) {
417✔
265
            $this->fixTokenMapFollowedByASingleSpace['constructor_public'] = CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC;
417✔
266
        }
267

268
        if (isset($this->fixTokenMapFollowedByASingleSpace['protected'])) {
417✔
269
            $this->fixTokenMapFollowedByASingleSpace['constructor_protected'] = CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED;
417✔
270
        }
271

272
        if (isset($this->fixTokenMapFollowedByASingleSpace['private'])) {
417✔
273
            $this->fixTokenMapFollowedByASingleSpace['constructor_private'] = CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE;
417✔
274
        }
275
    }
276

277
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
278
    {
279
        $tokenKindsContainASingleSpace = array_values($this->fixTokenMapContainASingleSpace);
399✔
280

281
        for ($index = $tokens->count() - 1; $index > 0; --$index) {
399✔
282
            if ($tokens[$index]->isGivenKind($tokenKindsContainASingleSpace)) {
397✔
283
                $token = $tokens[$index];
6✔
284

285
                if (
286
                    $token->isGivenKind(T_YIELD_FROM)
6✔
287
                    && 'yield from' !== strtolower($token->getContent())
6✔
288
                ) {
289
                    $tokens[$index] = new Token([T_YIELD_FROM, Preg::replace(
6✔
290
                        '/\s+/',
6✔
291
                        ' ',
6✔
292
                        $token->getContent()
6✔
293
                    )]);
6✔
294
                }
295
            }
296
        }
297

298
        $tokenKindsPrecededByASingleSpace = array_values($this->fixTokenMapPrecededByASingleSpace);
399✔
299

300
        for ($index = $tokens->count() - 1; $index > 0; --$index) {
399✔
301
            if ($tokens[$index]->isGivenKind($tokenKindsPrecededByASingleSpace)) {
397✔
302
                if (!$this->isFullLineCommentBefore($tokens, $index)) {
29✔
303
                    $tokens->ensureWhitespaceAtIndex($index - 1, 1, ' ');
27✔
304
                }
305
            }
306
        }
307

308
        $tokenKindsFollowedByASingleSpace = array_values($this->fixTokenMapFollowedByASingleSpace);
399✔
309

310
        for ($index = $tokens->count() - 2; $index >= 0; --$index) {
399✔
311
            $token = $tokens[$index];
397✔
312

313
            if (!$token->isGivenKind($tokenKindsFollowedByASingleSpace)) {
397✔
314
                continue;
397✔
315
            }
316

317
            $whitespaceTokenIndex = $index + 1;
380✔
318

319
            if ($tokens[$whitespaceTokenIndex]->equalsAny([',', ':', ';', ')', [CT::T_ARRAY_SQUARE_BRACE_CLOSE], [CT::T_DESTRUCTURING_SQUARE_BRACE_CLOSE]])) {
380✔
320
                continue;
5✔
321
            }
322

323
            if (
324
                $token->isGivenKind(T_STATIC)
375✔
325
                && !$tokens[$tokens->getNextMeaningfulToken($index)]->isGivenKind([T_FN, T_FUNCTION, T_NS_SEPARATOR, T_STRING, T_VARIABLE, CT::T_ARRAY_TYPEHINT, CT::T_NULLABLE_TYPE])
375✔
326
            ) {
327
                continue;
2✔
328
            }
329

330
            if ($token->isGivenKind(T_OPEN_TAG)) {
373✔
331
                if ($tokens[$whitespaceTokenIndex]->equals([T_WHITESPACE]) && !str_contains($tokens[$whitespaceTokenIndex]->getContent(), "\n") && !str_contains($token->getContent(), "\n")) {
21✔
332
                    $tokens->clearAt($whitespaceTokenIndex);
2✔
333
                }
334

335
                continue;
21✔
336
            }
337

338
            if ($token->isGivenKind(T_CLASS) && $tokens[$tokens->getNextMeaningfulToken($index)]->equals('(')) {
368✔
339
                continue;
1✔
340
            }
341

342
            if ($token->isGivenKind([T_EXTENDS, T_IMPLEMENTS]) && $this->isMultilineExtendsOrImplementsWithMoreThanOneAncestor($tokens, $index)) {
367✔
343
                continue;
2✔
344
            }
345

346
            if ($token->isGivenKind(T_RETURN) && $this->isMultiLineReturn($tokens, $index)) {
365✔
347
                continue;
10✔
348
            }
349

350
            if ($token->isGivenKind(T_CONST) && $this->isMultilineCommaSeparatedConstant($tokens, $index)) {
355✔
351
                continue;
2✔
352
            }
353

354
            if ($token->isComment() || $token->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
353✔
355
                if ($tokens[$whitespaceTokenIndex]->equals([T_WHITESPACE]) && str_contains($tokens[$whitespaceTokenIndex]->getContent(), "\n")) {
4✔
356
                    continue;
4✔
357
                }
358
            }
359

360
            if ($tokens[$whitespaceTokenIndex]->isWhitespace() && str_contains($tokens[$whitespaceTokenIndex]->getContent(), "\n")) {
353✔
361
                $nextNextToken = $tokens[$whitespaceTokenIndex + 1];
82✔
362
                if (
363
                    $nextNextToken->isGivenKind(FCT::T_ATTRIBUTE)
82✔
364
                    || $nextNextToken->isComment() && str_starts_with($nextNextToken->getContent(), '#[')
82✔
365
                ) {
366
                    continue;
1✔
367
                }
368

369
                if ($nextNextToken->isGivenKind(T_DOC_COMMENT)) {
81✔
370
                    continue;
1✔
371
                }
372
            }
373

374
            $tokens->ensureWhitespaceAtIndex($whitespaceTokenIndex, 0, ' ');
351✔
375
        }
376
    }
377

378
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
379
    {
380
        $tokenMapContainASingleSpaceKeys = array_keys(self::TOKEN_MAP_CONTAIN_A_SINGLE_SPACE);
417✔
381
        $tokenMapPrecededByASingleSpaceKeys = array_keys(self::TOKEN_MAP_PRECEDED_BY_A_SINGLE_SPACE);
417✔
382
        $tokenMapFollowedByASingleSpaceKeys = array_keys(self::TOKEN_MAP_FOLLOWED_BY_A_SINGLE_SPACE);
417✔
383

384
        return new FixerConfigurationResolver([
417✔
385
            (new FixerOptionBuilder('constructs_contain_a_single_space', 'List of constructs which must contain a single space.'))
417✔
386
                ->setAllowedTypes(['string[]'])
417✔
387
                ->setAllowedValues([new AllowedValueSubset($tokenMapContainASingleSpaceKeys)])
417✔
388
                ->setDefault($tokenMapContainASingleSpaceKeys)
417✔
389
                ->getOption(),
417✔
390
            (new FixerOptionBuilder('constructs_preceded_by_a_single_space', 'List of constructs which must be preceded by a single space.'))
417✔
391
                ->setAllowedTypes(['string[]'])
417✔
392
                ->setAllowedValues([new AllowedValueSubset($tokenMapPrecededByASingleSpaceKeys)])
417✔
393
                ->setDefault(['as', 'use_lambda'])
417✔
394
                ->getOption(),
417✔
395
            (new FixerOptionBuilder('constructs_followed_by_a_single_space', 'List of constructs which must be followed by a single space.'))
417✔
396
                ->setAllowedTypes(['string[]'])
417✔
397
                ->setAllowedValues([new AllowedValueSubset($tokenMapFollowedByASingleSpaceKeys)])
417✔
398
                ->setDefault($tokenMapFollowedByASingleSpaceKeys)
417✔
399
                ->getOption(),
417✔
400
        ]);
417✔
401
    }
402

403
    private function isMultiLineReturn(Tokens $tokens, int $index): bool
404
    {
405
        ++$index;
18✔
406
        $tokenFollowingReturn = $tokens[$index];
18✔
407

408
        if (
409
            !$tokenFollowingReturn->isGivenKind(T_WHITESPACE)
18✔
410
            || !str_contains($tokenFollowingReturn->getContent(), "\n")
18✔
411
        ) {
412
            return false;
8✔
413
        }
414

415
        $nestedCount = 0;
13✔
416

417
        for ($indexEnd = \count($tokens) - 1, ++$index; $index < $indexEnd; ++$index) {
13✔
418
            if (str_contains($tokens[$index]->getContent(), "\n")) {
13✔
419
                return true;
10✔
420
            }
421

422
            if ($tokens[$index]->equals('{')) {
13✔
423
                ++$nestedCount;
1✔
424
            } elseif ($tokens[$index]->equals('}')) {
13✔
425
                --$nestedCount;
1✔
426
            } elseif (0 === $nestedCount && $tokens[$index]->equalsAny([';', [T_CLOSE_TAG]])) {
13✔
UNCOV
427
                break;
×
428
            }
429
        }
430

431
        return false;
3✔
432
    }
433

434
    private function isMultilineExtendsOrImplementsWithMoreThanOneAncestor(Tokens $tokens, int $index): bool
435
    {
436
        $hasMoreThanOneAncestor = false;
26✔
437

438
        while (true) {
26✔
439
            ++$index;
26✔
440
            $token = $tokens[$index];
26✔
441

442
            if ($token->equals(',')) {
26✔
443
                $hasMoreThanOneAncestor = true;
6✔
444

445
                continue;
6✔
446
            }
447

448
            if ($token->equals('{')) {
26✔
449
                return false;
24✔
450
            }
451

452
            if ($hasMoreThanOneAncestor && str_contains($token->getContent(), "\n")) {
26✔
453
                return true;
2✔
454
            }
455
        }
456

UNCOV
457
        return LogicException('Not reachable code was reached.'); // @phpstan-ignore deadCode.unreachable
×
458
    }
459

460
    private function isMultilineCommaSeparatedConstant(Tokens $tokens, int $constantIndex): bool
461
    {
462
        $isMultilineConstant = false;
10✔
463
        $hasMoreThanOneConstant = false;
10✔
464
        $index = $constantIndex;
10✔
465
        while (!$tokens[$index]->equalsAny([';', [T_CLOSE_TAG]])) {
10✔
466
            ++$index;
10✔
467

468
            $isMultilineConstant = $isMultilineConstant || str_contains($tokens[$index]->getContent(), "\n");
10✔
469

470
            if ($tokens[$index]->equals(',')) {
10✔
471
                $hasMoreThanOneConstant = true;
2✔
472
            }
473

474
            $blockType = Tokens::detectBlockType($tokens[$index]);
10✔
475

476
            if (null !== $blockType && true === $blockType['isStart']) {
10✔
477
                $index = $tokens->findBlockEnd($blockType['type'], $index);
1✔
478
            }
479
        }
480

481
        return $hasMoreThanOneConstant && $isMultilineConstant;
10✔
482
    }
483

484
    private function isFullLineCommentBefore(Tokens $tokens, int $index): bool
485
    {
486
        $beforeIndex = $tokens->getPrevNonWhitespace($index);
29✔
487

488
        if (!$tokens[$beforeIndex]->isGivenKind([T_COMMENT])) {
29✔
489
            return false;
25✔
490
        }
491

492
        $content = $tokens[$beforeIndex]->getContent();
4✔
493

494
        return str_starts_with($content, '#') || str_starts_with($content, '//');
4✔
495
    }
496
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc