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

keradus / PHP-CS-Fixer / 16018263876

02 Jul 2025 06:58AM UTC coverage: 94.846% (-0.002%) from 94.848%
16018263876

push

github

keradus
debug2

28193 of 29725 relevant lines covered (94.85%)

45.34 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'|'private_set'|'protected'|'protected_set'|'public'|'public_set'|'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'|'private_set'|'protected'|'protected_set'|'public'|'public_set'|'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
        'private_set' => FCT::T_PRIVATE_SET,
120
        'protected' => T_PROTECTED,
121
        'protected_set' => FCT::T_PROTECTED_SET,
122
        'public' => T_PUBLIC,
123
        'public_set' => FCT::T_PUBLIC_SET,
124
        'readonly' => FCT::T_READONLY,
125
        'require' => T_REQUIRE,
126
        'require_once' => T_REQUIRE_ONCE,
127
        'return' => T_RETURN,
128
        'static' => T_STATIC,
129
        'switch' => T_SWITCH,
130
        'throw' => T_THROW,
131
        'trait' => T_TRAIT,
132
        'try' => T_TRY,
133
        'type_colon' => CT::T_TYPE_COLON,
134
        'use' => T_USE,
135
        'use_lambda' => CT::T_USE_LAMBDA,
136
        'use_trait' => CT::T_USE_TRAIT,
137
        'var' => T_VAR,
138
        'while' => T_WHILE,
139
        'yield' => T_YIELD,
140
        'yield_from' => T_YIELD_FROM,
141
    ];
142

143
    /**
144
     * @var array<string, int>
145
     */
146
    private array $fixTokenMapFollowedByASingleSpace = [];
147

148
    /**
149
     * @var array<string, int>
150
     */
151
    private array $fixTokenMapContainASingleSpace = [];
152

153
    /**
154
     * @var array<string, int>
155
     */
156
    private array $fixTokenMapPrecededByASingleSpace = [];
157

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

166
throw  new  \Exception();
167
'
3✔
168
                ),
3✔
169
                new CodeSample(
3✔
170
                    '<?php
3✔
171

172
function foo() { yield  from  baz(); }
173
',
3✔
174
                    [
3✔
175
                        'constructs_contain_a_single_space' => [
3✔
176
                            'yield_from',
3✔
177
                        ],
3✔
178
                        'constructs_followed_by_a_single_space' => [
3✔
179
                            'yield_from',
3✔
180
                        ],
3✔
181
                    ]
3✔
182
                ),
3✔
183
                new CodeSample(
3✔
184
                    '<?php
3✔
185

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

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

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

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

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

243
        return $tokens->isAnyTokenKindsFound($tokenKinds);
401✔
244
    }
245

246
    protected function configurePostNormalisation(): void
247
    {
248
        $this->fixTokenMapContainASingleSpace = [];
418✔
249

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

254
        $this->fixTokenMapPrecededByASingleSpace = [];
418✔
255

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

260
        $this->fixTokenMapFollowedByASingleSpace = [];
418✔
261

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

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

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

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

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

283
        for ($index = $tokens->count() - 1; $index > 0; --$index) {
400✔
284
            if ($tokens[$index]->isGivenKind($tokenKindsContainASingleSpace)) {
398✔
285
                $token = $tokens[$index];
6✔
286

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

300
        $tokenKindsPrecededByASingleSpace = array_values($this->fixTokenMapPrecededByASingleSpace);
400✔
301

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

310
        $tokenKindsFollowedByASingleSpace = array_values($this->fixTokenMapFollowedByASingleSpace);
400✔
311

312
        for ($index = $tokens->count() - 2; $index >= 0; --$index) {
400✔
313
            $token = $tokens[$index];
398✔
314

315
            if (!$token->isGivenKind($tokenKindsFollowedByASingleSpace)) {
398✔
316
                continue;
398✔
317
            }
318

319
            $whitespaceTokenIndex = $index + 1;
381✔
320

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

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

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

337
                continue;
22✔
338
            }
339

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

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

348
            if ($token->isGivenKind(T_RETURN) && $this->isMultiLineReturn($tokens, $index)) {
366✔
349
                continue;
10✔
350
            }
351

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

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

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

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

376
            $tokens->ensureWhitespaceAtIndex($whitespaceTokenIndex, 0, ' ');
352✔
377
        }
378
    }
379

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

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

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

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

417
        $nestedCount = 0;
13✔
418

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

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

433
        return false;
3✔
434
    }
435

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

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

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

447
                continue;
6✔
448
            }
449

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

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

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

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

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

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

476
            $blockType = Tokens::detectBlockType($tokens[$index]);
10✔
477

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

483
        return $hasMoreThanOneConstant && $isMultilineConstant;
10✔
484
    }
485

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

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

494
        $content = $tokens[$beforeIndex]->getContent();
4✔
495

496
        return str_starts_with($content, '#') || str_starts_with($content, '//');
4✔
497
    }
498
}
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