From 994ea8b6b9e2c17d731eafc4d6903c3231dc6dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Fri, 13 Feb 2026 13:35:09 +0100 Subject: [PATCH 1/5] feat: add support for converting is_null ternaries to null coalescing operator --- .../Fixture/is_null_function.php.inc | 34 +++++++++++++++ .../Ternary/TernaryToNullCoalescingRector.php | 41 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc diff --git a/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc new file mode 100644 index 00000000000..c7ea08e38af --- /dev/null +++ b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc @@ -0,0 +1,34 @@ + +----- + + diff --git a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php index 681fc708e99..7a4a4896bb1 100644 --- a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php +++ b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php @@ -10,6 +10,8 @@ use PhpParser\Node\Expr\BinaryOp\Coalesce; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\BinaryOp\NotIdentical; +use PhpParser\Node\Expr\BooleanNot; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Isset_; use PhpParser\Node\Expr\Ternary; use Rector\NodeTypeResolver\Node\AttributeKey; @@ -38,6 +40,7 @@ public function getRuleDefinition(): RuleDefinition [ new CodeSample('$value === null ? 10 : $value;', '$value ?? 10;'), new CodeSample('isset($value) ? $value : 10;', '$value ?? 10;'), + new CodeSample('is_null($value) ? 10 : $value;', '$value ?? 10;'), ] ); } @@ -59,6 +62,17 @@ public function refactor(Node $node): ?Node return $this->processTernaryWithIsset($node, $node->cond); } + if ($node->cond instanceof FuncCall && $this->isName($node->cond, 'is_null')) { + return $this->processTernaryWithIsNull($node, $node->cond, false); + } + + if ( + $node->cond instanceof BooleanNot && $node->cond->expr instanceof FuncCall + && $this->isName($node->cond->expr, 'is_null') + ) { + return $this->processTernaryWithIsNull($node, $node->cond->expr, true); + } + if ($node->cond instanceof Identical) { $checkedNode = $node->else; $fallbackNode = $node->if; @@ -95,6 +109,33 @@ public function provideMinPhpVersion(): int return PhpVersionFeature::NULL_COALESCE; } + private function processTernaryWithIsNull(Ternary $ternary, FuncCall $isNullFuncCall, bool $isNegated): ?Coalesce + { + if (count($isNullFuncCall->args) !== 1) { + return null; + } + + $checkedExpr = $isNullFuncCall->args[0]->value; + + if ($isNegated) { + if (! $ternary->if instanceof Expr) { + return null; + } + + if (! $this->nodeComparator->areNodesEqual($ternary->if, $checkedExpr)) { + return null; + } + + return new Coalesce($ternary->if, $ternary->else); + } + + if (! $this->nodeComparator->areNodesEqual($ternary->else, $checkedExpr)) { + return null; + } + + return new Coalesce($ternary->else, $ternary->if); + } + private function processTernaryWithIsset(Ternary $ternary, Isset_ $isset): ?Coalesce { if (! $ternary->if instanceof Expr) { From 8f7c80e94828e1d6e403789fa60314d6b60a139e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Fri, 13 Feb 2026 13:49:08 +0100 Subject: [PATCH 2/5] Fix errors --- .../Fixture/is_null_function.php.inc | 1 - .../Rector/Ternary/TernaryToNullCoalescingRector.php | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc index c7ea08e38af..9e5a19156e2 100644 --- a/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc +++ b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc @@ -31,4 +31,3 @@ function ternaryWithIsNull() } ?> - diff --git a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php index 7a4a4896bb1..2304630141a 100644 --- a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php +++ b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php @@ -115,7 +115,12 @@ private function processTernaryWithIsNull(Ternary $ternary, FuncCall $isNullFunc return null; } - $checkedExpr = $isNullFuncCall->args[0]->value; + $firstArg = $isNullFuncCall->args[0]; + if (! $firstArg instanceof Node\Arg) { + return null; + } + + $checkedExpr = $firstArg->value; if ($isNegated) { if (! $ternary->if instanceof Expr) { @@ -133,6 +138,10 @@ private function processTernaryWithIsNull(Ternary $ternary, FuncCall $isNullFunc return null; } + if (! $ternary->if instanceof Expr) { + return null; + } + return new Coalesce($ternary->else, $ternary->if); } From c8a3a9f4c2d79e23d6669a72c8b9dbd96552b926 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 14 Apr 2026 11:45:45 +0700 Subject: [PATCH 3/5] fix parenthesized --- .../Fixture/is_null_function.php.inc | 4 -- .../Fixture/is_null_with_parentheses.php.inc | 25 +++++++++ .../Ternary/TernaryToNullCoalescingRector.php | 56 +++++++++++++++++-- 3 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_with_parentheses.php.inc diff --git a/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc index 9e5a19156e2..b8f36c9c646 100644 --- a/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc +++ b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_function.php.inc @@ -9,8 +9,6 @@ function ternaryWithIsNull() $y = is_null($a) ? $b : $a; $z = !is_null($value) ? $value : 10; - - $w = !is_null($c) ? $c : $d; } ?> @@ -26,8 +24,6 @@ function ternaryWithIsNull() $y = $a ?? $b; $z = $value ?? 10; - - $w = $c ?? $d; } ?> diff --git a/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_with_parentheses.php.inc b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_with_parentheses.php.inc new file mode 100644 index 00000000000..7dc5b204a5b --- /dev/null +++ b/rules-tests/Php70/Rector/Ternary/TernaryToNullCoalescingRector/Fixture/is_null_with_parentheses.php.inc @@ -0,0 +1,25 @@ + +----- + diff --git a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php index 2304630141a..bcc9df73a08 100644 --- a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php +++ b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\BinaryOp; use PhpParser\Node\Expr\BinaryOp\Coalesce; use PhpParser\Node\Expr\BinaryOp\Identical; @@ -94,11 +95,11 @@ public function refactor(Node $node): ?Node $ternaryCompareNode = $node->cond; if ($this->isNullMatch($ternaryCompareNode->left, $ternaryCompareNode->right, $checkedNode)) { - return new Coalesce($checkedNode, $fallbackNode); + return $this->createCoalesce($checkedNode, $fallbackNode); } if ($this->isNullMatch($ternaryCompareNode->right, $ternaryCompareNode->left, $checkedNode)) { - return new Coalesce($checkedNode, $fallbackNode); + return $this->createCoalesce($checkedNode, $fallbackNode); } return null; @@ -131,7 +132,9 @@ private function processTernaryWithIsNull(Ternary $ternary, FuncCall $isNullFunc return null; } - return new Coalesce($ternary->if, $ternary->else); + $this->preserveWrappedFallback($ternary->else); + + return $this->createCoalesce($ternary->if, $ternary->else); } if (! $this->nodeComparator->areNodesEqual($ternary->else, $checkedExpr)) { @@ -142,7 +145,9 @@ private function processTernaryWithIsNull(Ternary $ternary, FuncCall $isNullFunc return null; } - return new Coalesce($ternary->else, $ternary->if); + $this->preserveWrappedFallback($ternary->if); + + return $this->createCoalesce($ternary->else, $ternary->if); } private function processTernaryWithIsset(Ternary $ternary, Isset_ $isset): ?Coalesce @@ -172,7 +177,7 @@ private function processTernaryWithIsset(Ternary $ternary, Isset_ $isset): ?Coal $ternary->else->setAttribute(AttributeKey::WRAPPED_IN_PARENTHESES, true); } - return new Coalesce($ternary->if, $ternary->else); + return $this->createCoalesce($ternary->if, $ternary->else); } private function isTernaryParenthesized(File $file, Expr $expr, Ternary $ternary): bool @@ -213,4 +218,45 @@ private function isNullMatch(Expr $possibleNullExpr, Expr $firstNode, Expr $seco return $this->nodeComparator->areNodesEqual($firstNode, $secondNode); } + + private function createCoalesce(Expr $checkedExpr, Expr $fallbackExpr): Coalesce + { + if ($this->isExprParenthesized($this->getFile(), $checkedExpr)) { + $checkedExpr->setAttribute(AttributeKey::WRAPPED_IN_PARENTHESES, true); + } + + return new Coalesce($checkedExpr, $fallbackExpr); + } + + private function preserveWrappedFallback(Expr $expr): void + { + if (! ($expr instanceof BinaryOp || $expr instanceof Ternary)) { + return; + } + + if (! $this->isExprParenthesized($this->getFile(), $expr)) { + return; + } + + $expr->setAttribute(AttributeKey::WRAPPED_IN_PARENTHESES, true); + } + + private function isExprParenthesized(File $file, Expr $expr): bool + { + $oldTokens = $file->getOldTokens(); + $startTokenPos = $expr->getStartTokenPos(); + $endTokenPos = $expr->getEndTokenPos(); + + while (isset($oldTokens[$startTokenPos - 1]) && trim((string) $oldTokens[$startTokenPos - 1]) === '') { + --$startTokenPos; + } + + while (isset($oldTokens[$endTokenPos + 1]) && trim((string) $oldTokens[$endTokenPos + 1]) === '') { + ++$endTokenPos; + } + + return isset($oldTokens[$startTokenPos - 1], $oldTokens[$endTokenPos + 1]) + && (string) $oldTokens[$startTokenPos - 1] === '(' + && (string) $oldTokens[$endTokenPos + 1] === ')'; + } } From b438aed36141fb1462482174e63acaa3ad0e5ed2 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 14 Apr 2026 11:47:17 +0700 Subject: [PATCH 4/5] cs --- .../Php70/Rector/Ternary/TernaryToNullCoalescingRector.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php index bcc9df73a08..5c4f6291486 100644 --- a/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php +++ b/rules/Php70/Rector/Ternary/TernaryToNullCoalescingRector.php @@ -5,8 +5,8 @@ namespace Rector\Php70\Rector\Ternary; use PhpParser\Node; +use PhpParser\Node\Arg; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\BinaryOp; use PhpParser\Node\Expr\BinaryOp\Coalesce; use PhpParser\Node\Expr\BinaryOp\Identical; @@ -117,7 +117,7 @@ private function processTernaryWithIsNull(Ternary $ternary, FuncCall $isNullFunc } $firstArg = $isNullFuncCall->args[0]; - if (! $firstArg instanceof Node\Arg) { + if (! $firstArg instanceof Arg) { return null; } @@ -230,7 +230,7 @@ private function createCoalesce(Expr $checkedExpr, Expr $fallbackExpr): Coalesce private function preserveWrappedFallback(Expr $expr): void { - if (! ($expr instanceof BinaryOp || $expr instanceof Ternary)) { + if (! $expr instanceof BinaryOp && ! $expr instanceof Ternary) { return; } From e8c4b94bba15142b7af9266b5588cf516229971a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 14 Apr 2026 11:56:31 +0700 Subject: [PATCH 5/5] trigger CI