Skip to content

Incorrect behavior of TernaryToNullCoalescingRector: $a !== null ? $a : ($b ? $c : $d) becomes $a ?? $b ? $c : $d (parentheses dropped, semantics change) #9912

Closed
rectorphp/rector-src
#8674

Description

@Enfiladeguide

Bug Report

Subject Details
Rector version 2.6.6 (rule source unchanged on rector-src main as of 2026-09-23)
Installed as composer dependency
PHP 8.5.4

We ran into this while moving a mid-sized PHP 8.5 codebase to typed signatures. No test or static check noticed it; a byte-for-byte comparison of every rendered page before and after the Rector run did, because a label silently disappeared.

Minimal PHP Code Causing Issue

https://getrector.com/demo/ddc74e4e-f838-4b17-9335-cca401864bd9

<?php
function g($a, $b) { return ($a !== null) ? $a : ($b ? 'yes' : 'no'); }
function h($a, $b) { return null === $a ? ($b ? 'yes' : 'no') : $a; }
function m($a, $b) { return isset($a) ? $a : ($b ? 'yes' : 'no'); } // control: already fixed via #9649

Config: RectorConfig::configure()->withRules([TernaryToNullCoalescingRector::class]);

Actual diff:

-function g($a, $b) { return ($a !== null) ? $a : ($b ? 'yes' : 'no'); }
-function h($a, $b) { return null === $a ? ($b ? 'yes' : 'no') : $a; }
+function g($a, $b) { return $a ?? $b ? 'yes' : 'no'; }
+function h($a, $b) { return $a ?? $b ? 'yes' : 'no'; }
-function m($a, $b) { return isset($a) ? $a : ($b ? 'yes' : 'no'); }
+function m($a, $b) { return $a ?? ($b ? 'yes' : 'no'); }

?? binds tighter than ?:, so the result parses as ($a ?? $b) ? 'yes' : 'no'. For $a = 'abc', $b = false the original returns 'abc', the rewritten code returns 'yes'.

Expected Behaviour

return $a ?? ($b ? 'yes' : 'no'); — as the isset() / is_null() branches already do since rector-src #7889, #7916 and #7969 (issues #9649, #9674).

Cause

In refactor(), the Identical / NotIdentical path calls createCoalesce($checkedNode, $fallbackNode) without preserveWrappedFallback($fallbackNode). The underlying cause is nikic/PHP-Parser#1119: the format-preserving printer does not re-add parentheses for an unchanged node moved under a new parent. A nested ternary fallback is always parenthesized in valid PHP 8 code, so every such case is affected.

Suggested fix: call $this->preserveWrappedFallback($fallbackNode) on that path as well, with a fixture (e.g. keep_right_parentheses_not_identical.php.inc) covering both !== null and === null. Happy to send a PR if that direction is welcome.

Activity

  1. TomasVotruba commented on Oct 16, 2026

    @TomasVotruba
    Member

    Thanks for reporting 👍

    Fixedi in rectorphp/rector-src#8674

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions