From 3241ec9522040358a1b251076fc45a3c3764bab7 Mon Sep 17 00:00:00 2001 From: Guillaume Sainthillier Date: Wed, 16 Sep 2026 11:48:42 +0200 Subject: [PATCH 1/2] [Php80] Add TernaryToNullsafeCoalesceRector --- config/set/php-version-based.php | 2 + config/set/php80.php | 2 + .../Fixture/identical_null_fallback.php.inc | 31 +++ .../Fixture/null_fallback.php.inc | 37 ++++ .../Fixture/skip_nullable_return.php.inc | 13 ++ .../skip_side_effect_checked_expr.php.inc | 18 ++ .../Fixture/skip_wrapped_call.php.inc | 13 ++ .../Fixture/string_fallback.php.inc | 27 +++ .../Source/SomeObject.php | 20 ++ .../TernaryToNullsafeCoalesceRectorTest.php | 28 +++ .../config/configured_rule.php | 11 + .../TernaryToNullsafeCoalesceRector.php | 197 ++++++++++++++++++ 12 files changed, 399 insertions(+) create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/null_fallback.php.inc create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_nullable_return.php.inc create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_side_effect_checked_expr.php.inc create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_wrapped_call.php.inc create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/string_fallback.php.inc create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Source/SomeObject.php create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/TernaryToNullsafeCoalesceRectorTest.php create mode 100644 rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/config/configured_rule.php create mode 100644 rules/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector.php diff --git a/config/set/php-version-based.php b/config/set/php-version-based.php index 79ea60b7562..42289f31469 100644 --- a/config/set/php-version-based.php +++ b/config/set/php-version-based.php @@ -100,6 +100,7 @@ use Rector\Php80\Rector\NotIdentical\StrContainsRector; use Rector\Php80\Rector\Switch_\ChangeSwitchToMatchRector; use Rector\Php80\Rector\Ternary\GetDebugTypeRector; +use Rector\Php80\Rector\Ternary\TernaryToNullsafeCoalesceRector; use Rector\Php81\Rector\Array_\ArrayToFirstClassCallableRector; use Rector\Php81\Rector\Class_\MyCLabsClassToEnumRector; use Rector\Php81\Rector\Class_\SpatieEnumClassToEnumRector; @@ -257,6 +258,7 @@ StringableForToStringRector::class, ClassOnObjectRector::class, GetDebugTypeRector::class, + TernaryToNullsafeCoalesceRector::class, RemoveUnusedVariableInCatchRector::class, ClassPropertyAssignToConstructorPromotionRector::class, ChangeSwitchToMatchRector::class, diff --git a/config/set/php80.php b/config/set/php80.php index bab1593481a..502b7650c0e 100644 --- a/config/set/php80.php +++ b/config/set/php80.php @@ -23,6 +23,7 @@ use Rector\Php80\Rector\NotIdentical\StrContainsRector; use Rector\Php80\Rector\Switch_\ChangeSwitchToMatchRector; use Rector\Php80\Rector\Ternary\GetDebugTypeRector; +use Rector\Php80\Rector\Ternary\TernaryToNullsafeCoalesceRector; use Rector\Renaming\Rector\FuncCall\RenameFunctionRector; use Rector\Transform\Rector\StaticCall\StaticCallToFuncCallRector; use Rector\Transform\ValueObject\StaticCallToFuncCall; @@ -35,6 +36,7 @@ StringableForToStringRector::class, ClassOnObjectRector::class, GetDebugTypeRector::class, + TernaryToNullsafeCoalesceRector::class, RemoveUnusedVariableInCatchRector::class, ClassPropertyAssignToConstructorPromotionRector::class, ChangeSwitchToMatchRector::class, diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc new file mode 100644 index 00000000000..b6c788a7afb --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc @@ -0,0 +1,31 @@ +getName(); + } +} + +?> +----- +getName(); + } +} + +?> diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/null_fallback.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/null_fallback.php.inc new file mode 100644 index 00000000000..73a193cede5 --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/null_fallback.php.inc @@ -0,0 +1,37 @@ +getName() : null; + $property = $someObject !== null ? $someObject->name : null; + + return [$name, $property]; + } +} + +?> +----- +getName(); + $property = $someObject?->name; + + return [$name, $property]; + } +} + +?> diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_nullable_return.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_nullable_return.php.inc new file mode 100644 index 00000000000..ae67cabd948 --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_nullable_return.php.inc @@ -0,0 +1,13 @@ +findName() : ''; + } +} diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_side_effect_checked_expr.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_side_effect_checked_expr.php.inc new file mode 100644 index 00000000000..37688169efc --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_side_effect_checked_expr.php.inc @@ -0,0 +1,18 @@ +resolve() ? $this->resolve()->getName() : null; + } + + private function resolve(): ?SomeObject + { + return new SomeObject(); + } +} diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_wrapped_call.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_wrapped_call.php.inc new file mode 100644 index 00000000000..7b6a37c7497 --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/skip_wrapped_call.php.inc @@ -0,0 +1,13 @@ +getName()) : ''; + } +} diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/string_fallback.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/string_fallback.php.inc new file mode 100644 index 00000000000..f46cd226825 --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/string_fallback.php.inc @@ -0,0 +1,27 @@ +format('d/m/Y h:i:s') : ''; + } +} + +?> +----- +format('d/m/Y h:i:s') ?? ''; + } +} + +?> diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Source/SomeObject.php b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Source/SomeObject.php new file mode 100644 index 00000000000..6be342ab4f7 --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Source/SomeObject.php @@ -0,0 +1,20 @@ +name; + } + + public function findName(): ?string + { + return $this->name; + } +} diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/TernaryToNullsafeCoalesceRectorTest.php b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/TernaryToNullsafeCoalesceRectorTest.php new file mode 100644 index 00000000000..59ad3d44b87 --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/TernaryToNullsafeCoalesceRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/config/configured_rule.php b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/config/configured_rule.php new file mode 100644 index 00000000000..36ab8934e2a --- /dev/null +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/config/configured_rule.php @@ -0,0 +1,11 @@ +withPhpVersion(PhpVersion::PHP_80) + ->withRules([TernaryToNullsafeCoalesceRector::class]); diff --git a/rules/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector.php b/rules/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector.php new file mode 100644 index 00000000000..3ee42ec82bf --- /dev/null +++ b/rules/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector.php @@ -0,0 +1,197 @@ +format('d/m/Y') : ''; +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +$value = $dateTime?->format('d/m/Y') ?? ''; +CODE_SAMPLE + ), + ] + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [Ternary::class]; + } + + /** + * @param Ternary $node + */ + public function refactor(Node $node): ?Node + { + // short ternary "$a ?: $b" has no "if" branch + if (! $node->if instanceof Expr) { + return null; + } + + $checkedExpr = $this->matchNullComparedExpr($node->cond); + if (! $checkedExpr instanceof Expr) { + return null; + } + + if ($node->cond instanceof NotIdentical) { + // null !== $a ? $a->call() : $fallback + $callExpr = $node->if; + $fallbackExpr = $node->else; + } else { + // null === $a ? $fallback : $a->call() + $callExpr = $node->else; + $fallbackExpr = $node->if; + } + + // re-evaluating the checked expression must stay free of side effects + if (! $this->isPureExpr($checkedExpr)) { + return null; + } + + $nullsafeExpr = $this->createNullsafeChain($callExpr, $checkedExpr); + if (! $nullsafeExpr instanceof Expr) { + return null; + } + + // "$a !== null ? $a->call() : null" needs no fallback at all + if ($this->valueResolver->isNull($fallbackExpr)) { + return $nullsafeExpr; + } + + if ($this->shouldSkipCoalesceFallback($callExpr)) { + return null; + } + + return new Coalesce($nullsafeExpr, $fallbackExpr); + } + + public function provideMinPhpVersion(): int + { + return PhpVersionFeature::NULLSAFE_OPERATOR; + } + + /** + * Resolves the expression compared against null, for both "null !== $a" and "$a !== null" orders. + */ + private function matchNullComparedExpr(Expr $expr): ?Expr + { + if (! $expr instanceof NotIdentical && ! $expr instanceof Identical) { + return null; + } + + if ($this->valueResolver->isNull($expr->left)) { + return $expr->right; + } + + if ($this->valueResolver->isNull($expr->right)) { + return $expr->left; + } + + return null; + } + + /** + * Rewrites the deepest link of "$a->b()->c" that is rooted in $checkedExpr into its nullsafe + * counterpart; the rest of the chain short-circuits on its own. + */ + private function createNullsafeChain(Expr $expr, Expr $checkedExpr): ?Expr + { + if ($expr instanceof MethodCall) { + if ($this->nodeComparator->areNodesEqual($expr->var, $checkedExpr)) { + return new NullsafeMethodCall($expr->var, $expr->name, $expr->args); + } + + $nestedExpr = $this->createNullsafeChain($expr->var, $checkedExpr); + if (! $nestedExpr instanceof Expr) { + return null; + } + + return new MethodCall($nestedExpr, $expr->name, $expr->args); + } + + if ($expr instanceof PropertyFetch) { + if ($this->nodeComparator->areNodesEqual($expr->var, $checkedExpr)) { + return new NullsafePropertyFetch($expr->var, $expr->name); + } + + $nestedExpr = $this->createNullsafeChain($expr->var, $checkedExpr); + if (! $nestedExpr instanceof Expr) { + return null; + } + + return new PropertyFetch($nestedExpr, $expr->name); + } + + return null; + } + + private function isPureExpr(Expr $expr): bool + { + if ($expr instanceof Variable) { + return true; + } + + if ($expr instanceof PropertyFetch) { + return $this->isPureExpr($expr->var); + } + + return $expr instanceof StaticPropertyFetch; + } + + /** + * Guards the "?? $fallback" rewrite. + * + * The ternary and the coalesce only agree while the call itself cannot return null: + * + * null !== $a ? $a->find() : '' // $a->find() returning null yields null + * $a?->find() ?? '' // $a->find() returning null yields '' + */ + private function shouldSkipCoalesceFallback(Expr $callExpr): bool + { + return TypeCombinator::containsNull($this->getType($callExpr)); + } +} From c28645052f4649ecd316435dfcfc974c770f95a7 Mon Sep 17 00:00:00 2001 From: Guillaume Sainthillier Date: Wed, 16 Sep 2026 12:18:56 +0200 Subject: [PATCH 2/2] Add fixture for null on the right side of identical compare --- .../Fixture/identical_null_fallback.php.inc | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc index b6c788a7afb..96bfa236898 100644 --- a/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc +++ b/rules-tests/Php80/Rector/Ternary/TernaryToNullsafeCoalesceRector/Fixture/identical_null_fallback.php.inc @@ -6,9 +6,12 @@ use Rector\Tests\Php80\Rector\Ternary\TernaryToNullsafeCoalesceRector\Source\Som final class IdenticalNullFallback { - public function run(?SomeObject $someObject): ?string + public function run(?SomeObject $someObject): array { - return null === $someObject ? null : $someObject->getName(); + $name = null === $someObject ? null : $someObject->getName(); + $property = $someObject === null ? null : $someObject->name; + + return [$name, $property]; } } @@ -22,9 +25,12 @@ use Rector\Tests\Php80\Rector\Ternary\TernaryToNullsafeCoalesceRector\Source\Som final class IdenticalNullFallback { - public function run(?SomeObject $someObject): ?string + public function run(?SomeObject $someObject): array { - return $someObject?->getName(); + $name = $someObject?->getName(); + $property = $someObject?->name; + + return [$name, $property]; } }