• 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

99.22
/src/Fixer/LanguageConstruct/NullableTypeDeclarationFixer.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\FixerConfigurationResolver;
21
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
22
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
23
use PhpCsFixer\FixerDefinition\FixerDefinition;
24
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
25
use PhpCsFixer\FixerDefinition\VersionSpecification;
26
use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
27
use PhpCsFixer\Preg;
28
use PhpCsFixer\Tokenizer\Analyzer\Analysis\TypeAnalysis;
29
use PhpCsFixer\Tokenizer\Analyzer\FunctionsAnalyzer;
30
use PhpCsFixer\Tokenizer\CT;
31
use PhpCsFixer\Tokenizer\FCT;
32
use PhpCsFixer\Tokenizer\Token;
33
use PhpCsFixer\Tokenizer\Tokens;
34
use PhpCsFixer\Tokenizer\TokensAnalyzer;
35

36
/**
37
 * @author John Paul E. Balandan, CPA <paulbalandan@gmail.com>
38
 *
39
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
40
 *
41
 * @phpstan-type _AutogeneratedInputConfiguration array{
42
 *  syntax?: 'question_mark'|'union',
43
 * }
44
 * @phpstan-type _AutogeneratedComputedConfiguration array{
45
 *  syntax: 'question_mark'|'union',
46
 * }
47
 */
48
final class NullableTypeDeclarationFixer extends AbstractFixer implements ConfigurableFixerInterface
49
{
50
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
51
    use ConfigurableFixerTrait;
52

53
    private const OPTION_SYNTAX_UNION = 'union';
54
    private const OPTION_SYNTAX_QUESTION_MARK = 'question_mark';
55
    private const PROPERTY_MODIFIERS = [T_PRIVATE, T_PROTECTED, T_PUBLIC, T_STATIC, T_VAR, FCT::T_READONLY];
56

57
    private int $candidateTokenKind;
58

59
    public function getDefinition(): FixerDefinitionInterface
60
    {
61
        return new FixerDefinition(
3✔
62
            'Nullable single type declaration should be standardised using configured syntax.',
3✔
63
            [
3✔
64
                new VersionSpecificCodeSample(
3✔
65
                    "<?php\nfunction bar(null|int \$value, null|\\Closure \$callable): int|null {}\n",
3✔
66
                    new VersionSpecification(8_00_00)
3✔
67
                ),
3✔
68
                new VersionSpecificCodeSample(
3✔
69
                    "<?php\nfunction baz(?int \$value, ?\\stdClass \$obj, ?array \$config): ?int {}\n",
3✔
70
                    new VersionSpecification(8_00_00),
3✔
71
                    ['syntax' => self::OPTION_SYNTAX_UNION]
3✔
72
                ),
3✔
73
                new VersionSpecificCodeSample(
3✔
74
                    '<?php
3✔
75
class ValueObject
76
{
77
    public null|string $name;
78
    public ?int $count;
79
    public null|bool $internal;
80
    public null|\Closure $callback;
81
}
82
',
3✔
83
                    new VersionSpecification(8_00_00),
3✔
84
                    ['syntax' => self::OPTION_SYNTAX_QUESTION_MARK]
3✔
85
                ),
3✔
86
            ]
3✔
87
        );
3✔
88
    }
89

90
    public function isCandidate(Tokens $tokens): bool
91
    {
92
        return \PHP_VERSION_ID >= 8_00_00 && $tokens->isTokenKindFound($this->candidateTokenKind);
21✔
93
    }
94

95
    /**
96
     * {@inheritdoc}
97
     *
98
     * Must run before OrderedTypesFixer, TypesSpacesFixer.
99
     * Must run after NullableTypeDeclarationForDefaultNullValueFixer.
100
     */
101
    public function getPriority(): int
102
    {
103
        return 2;
1✔
104
    }
105

106
    protected function configurePostNormalisation(): void
107
    {
108
        $this->candidateTokenKind = self::OPTION_SYNTAX_QUESTION_MARK === $this->configuration['syntax']
30✔
109
            ? CT::T_TYPE_ALTERNATION // `|` -> `?`
30✔
110
            : CT::T_NULLABLE_TYPE; // `?` -> `|`
12✔
111
    }
112

113
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
114
    {
115
        return new FixerConfigurationResolver([
30✔
116
            (new FixerOptionBuilder('syntax', 'Whether to use question mark (`?`) or explicit `null` union for nullable type.'))
30✔
117
                ->setAllowedValues([self::OPTION_SYNTAX_UNION, self::OPTION_SYNTAX_QUESTION_MARK])
30✔
118
                ->setDefault(self::OPTION_SYNTAX_QUESTION_MARK)
30✔
119
                ->getOption(),
30✔
120
        ]);
30✔
121
    }
122

123
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
124
    {
125
        $functionsAnalyzer = new FunctionsAnalyzer();
19✔
126

127
        foreach (array_reverse($this->getElements($tokens), true) as $index => $type) {
19✔
128
            if ('property' === $type) {
19✔
129
                $this->normalizePropertyType($tokens, $index);
8✔
130

131
                continue;
8✔
132
            }
133

134
            $this->normalizeMethodReturnType($functionsAnalyzer, $tokens, $index);
13✔
135
            $this->normalizeMethodArgumentType($functionsAnalyzer, $tokens, $index);
13✔
136
        }
137
    }
138

139
    /**
140
     * @return array<int, string>
141
     *
142
     * @phpstan-return array<int, 'function'|'property'>
143
     */
144
    private function getElements(Tokens $tokens): array
145
    {
146
        $tokensAnalyzer = new TokensAnalyzer($tokens);
19✔
147

148
        $elements = array_map(
19✔
149
            static fn (array $element): string => 'method' === $element['type'] ? 'function' : $element['type'],
19✔
150
            array_filter(
19✔
151
                $tokensAnalyzer->getClassyElements(),
19✔
152
                static fn (array $element): bool => \in_array($element['type'], ['method', 'property'], true)
19✔
153
            )
19✔
154
        );
19✔
155

156
        foreach ($tokens as $index => $token) {
19✔
157
            if (
158
                $token->isGivenKind(T_FN)
19✔
159
                || ($token->isGivenKind(T_FUNCTION) && !isset($elements[$index]))
19✔
160
            ) {
161
                $elements[$index] = 'function';
9✔
162
            }
163
        }
164

165
        return $elements;
19✔
166
    }
167

168
    private function collectTypeAnalysis(Tokens $tokens, int $startIndex, int $endIndex): ?TypeAnalysis
169
    {
170
        $type = '';
8✔
171
        $typeStartIndex = $tokens->getNextMeaningfulToken($startIndex);
8✔
172
        $typeEndIndex = $typeStartIndex;
8✔
173

174
        for ($i = $typeStartIndex; $i < $endIndex; ++$i) {
8✔
175
            if ($tokens[$i]->isWhitespace() || $tokens[$i]->isComment()) {
8✔
176
                continue;
8✔
177
            }
178

179
            $type .= $tokens[$i]->getContent();
8✔
180
            $typeEndIndex = $i;
8✔
181
        }
182

183
        return '' !== $type ? new TypeAnalysis($type, $typeStartIndex, $typeEndIndex) : null;
8✔
184
    }
185

186
    private function isTypeNormalizable(TypeAnalysis $typeAnalysis): bool
187
    {
188
        $type = $typeAnalysis->getName();
19✔
189

190
        if ('null' === strtolower($type) || !$typeAnalysis->isNullable()) {
19✔
191
            return false;
9✔
192
        }
193

194
        if (str_contains($type, '&')) {
19✔
195
            return false; // skip DNF types
1✔
196
        }
197

198
        if (!str_contains($type, '|')) {
18✔
199
            return true;
12✔
200
        }
201

202
        return 1 === substr_count($type, '|') && Preg::match('/(?:\|null$|^null\|)/i', $type);
8✔
203
    }
204

205
    private function normalizePropertyType(Tokens $tokens, int $index): void
206
    {
207
        $propertyEndIndex = $index;
8✔
208
        do {
209
            $index = $tokens->getPrevMeaningfulToken($index);
8✔
210
        } while (!$tokens[$index]->isGivenKind(self::PROPERTY_MODIFIERS));
8✔
211

212
        $propertyType = $this->collectTypeAnalysis($tokens, $index, $propertyEndIndex);
8✔
213

214
        if (null === $propertyType || !$this->isTypeNormalizable($propertyType)) {
8✔
215
            return;
1✔
216
        }
217

218
        $this->normalizeNullableType($tokens, $propertyType);
7✔
219
    }
220

221
    private function normalizeMethodArgumentType(FunctionsAnalyzer $functionsAnalyzer, Tokens $tokens, int $index): void
222
    {
223
        foreach (array_reverse($functionsAnalyzer->getFunctionArguments($tokens, $index), true) as $argumentInfo) {
13✔
224
            $argumentType = $argumentInfo->getTypeAnalysis();
13✔
225

226
            if (null === $argumentType || !$this->isTypeNormalizable($argumentType)) {
13✔
227
                continue;
4✔
228
            }
229

230
            $this->normalizeNullableType($tokens, $argumentType);
12✔
231
        }
232
    }
233

234
    private function normalizeMethodReturnType(FunctionsAnalyzer $functionsAnalyzer, Tokens $tokens, int $index): void
235
    {
236
        $returnType = $functionsAnalyzer->getFunctionReturnType($tokens, $index);
13✔
237

238
        if (null === $returnType || !$this->isTypeNormalizable($returnType)) {
13✔
239
            return;
7✔
240
        }
241

242
        $this->normalizeNullableType($tokens, $returnType);
6✔
243
    }
244

245
    private function normalizeNullableType(Tokens $tokens, TypeAnalysis $typeAnalysis): void
246
    {
247
        $type = $typeAnalysis->getName();
17✔
248

249
        if (!str_contains($type, '|') && !str_contains($type, '&')) {
17✔
250
            $type = ($typeAnalysis->isNullable() ? '?' : '').$type;
12✔
251
        }
252

253
        $isQuestionMarkSyntax = self::OPTION_SYNTAX_QUESTION_MARK === $this->configuration['syntax'];
17✔
254

255
        if ($isQuestionMarkSyntax) {
17✔
256
            $normalizedType = $this->convertToNullableType($type);
7✔
257
            $normalizedTypeAsString = implode('', $normalizedType);
7✔
258
        } else {
259
            $normalizedType = $this->convertToExplicitUnionType($type);
11✔
260
            $normalizedTypeAsString = implode('|', $normalizedType);
11✔
261
        }
262

263
        if ($normalizedTypeAsString === $type) {
17✔
264
            return; // nothing to fix
2✔
265
        }
266

267
        $tokens->overrideRange(
17✔
268
            $typeAnalysis->getStartIndex(),
17✔
269
            $typeAnalysis->getEndIndex(),
17✔
270
            $this->createTypeDeclarationTokens($normalizedType, $isQuestionMarkSyntax)
17✔
271
        );
17✔
272

273
        $prevStartIndex = $typeAnalysis->getStartIndex() - 1;
17✔
274
        if (!$tokens[$prevStartIndex]->isWhitespace() && !$tokens[$prevStartIndex]->equals('(')) {
17✔
275
            $tokens->ensureWhitespaceAtIndex($prevStartIndex, 1, ' ');
1✔
276
        }
277
    }
278

279
    /**
280
     * @return list<string>
281
     */
282
    private function convertToNullableType(string $type): array
283
    {
284
        if (str_starts_with($type, '?')) {
7✔
285
            return [$type]; // no need to convert; already fixed
2✔
286
        }
287

288
        return ['?', Preg::replace('/(?:\|null$|^null\|)/i', '', $type)];
7✔
289
    }
290

291
    /**
292
     * @return list<string>
293
     */
294
    private function convertToExplicitUnionType(string $type): array
295
    {
296
        if (str_contains($type, '|')) {
11✔
UNCOV
297
            return [$type]; // no need to convert; already fixed
×
298
        }
299

300
        return ['null', substr($type, 1)];
11✔
301
    }
302

303
    /**
304
     * @param list<string> $types
305
     *
306
     * @return list<Token>
307
     */
308
    private function createTypeDeclarationTokens(array $types, bool $isQuestionMarkSyntax): array
309
    {
310
        static $specialTypes = [
17✔
311
            '?' => CT::T_NULLABLE_TYPE,
17✔
312
            'array' => CT::T_ARRAY_TYPEHINT,
17✔
313
            'callable' => T_CALLABLE,
17✔
314
            'static' => T_STATIC,
17✔
315
        ];
17✔
316

317
        $count = \count($types);
17✔
318
        $newTokens = [];
17✔
319

320
        foreach ($types as $index => $type) {
17✔
321
            if (isset($specialTypes[strtolower($type)])) {
17✔
322
                $newTokens[] = new Token([$specialTypes[strtolower($type)], $type]);
13✔
323
            } else {
324
                foreach (explode('\\', $type) as $nsIndex => $value) {
16✔
325
                    if (0 === $nsIndex && '' === $value) {
16✔
326
                        continue;
4✔
327
                    }
328

329
                    if ($nsIndex > 0) {
16✔
330
                        $newTokens[] = new Token([T_NS_SEPARATOR, '\\']);
4✔
331
                    }
332

333
                    $newTokens[] = new Token([T_STRING, $value]);
16✔
334
                }
335
            }
336

337
            if ($index <= $count - 2 && !$isQuestionMarkSyntax) {
17✔
338
                $newTokens[] = new Token([CT::T_TYPE_ALTERNATION, '|']);
11✔
339
            }
340
        }
341

342
        return $newTokens;
17✔
343
    }
344
}
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