Skip to content

Report ternary operators that always give one of their branches - #6894

Open
SanderMuller wants to merge 6 commits into
phpstan:2.3.xfrom
SanderMuller:unnecessary-ternary
Open

SanderMuller wants to merge 6 commits into
phpstan:2.3.xfrom
SanderMuller:unnecessary-ternary

Conversation

@SanderMuller

Copy link
Copy Markdown
Contributor

Adds a bleeding edge rule for a ternary that always gives one of its branches, including the short ?: form.

fn (?int $i) => $i !== null ? $i : null; // reported
fn (?int $i) => $i === null ? null : $i; // reported
fn (?Foo $f) => $f ?: null;              // reported
fn (?int $i) => $i ?: null;              // not reported, 0 becomes null

It stays quiet when:

  • a dropped or moved expression can have side effects. Only variables, literals, constants, ===, !==, !, &&, ||, instanceof and calls to functions without side effects count as pure. Property fetches do not, because of __get and hooks.
  • the moved branch has a call, for example $s === false ? 0 : strlen($s). Self-analysis found this one.
  • the value is a float, because -0.0 ?: 0.0 gives 0.0.
  • a truthiness check is on a value that can be a falsy object, for example an empty SimpleXMLElement. The rule uses toBoolean() for this, so ?Countable ?: null is still reported until toBoolean() returns bool for these interfaces. Return bool from toBoolean() of object, object{} and interfaces that SimpleXMLElement, GMP or BcMath\Number can implement #6892 proposes that.

On a 4.5k-file real-world project it reports one true positive, with no measurable CPU cost (113.9 s on, 113.1 s off, medians of 3).

Closes phpstan/phpstan#15608

🤖 Generated with Claude Code

SanderMuller and others added 3 commits November 3, 2026 18:28
The rule is behind the unnecessaryTernary toggle, which bleeding edge
turns on. It covers the short ?: form too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The constant condition and float checks now read the constant values,
and the object check uses isObject(), so a mutant of them changes the
result. New test cases cover a constant condition and a function that
can have side effects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@staabm
staabm force-pushed the unnecessary-ternary branch from 453d403 to 5cb3ce4 Compare November 3, 2026 17:28
* Whether evaluating the expression can only produce its value, so that
* dropping it or evaluating it once more does not change anything.
*/
private function isPure(Expr $expr, Scope $scope): bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what I don't like about the PR is

  • isPure() function impl / don't we have this logic somewhere else already?
  • use of NodeTraverser which can be slow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The engine already knows this. The cond, if and else results have impure points, but a rule on Ternary cannot read them. Since #6418, NullCoalesceRule reads them from CoalesceExpressionNode, which CoalesceHandler emits after it processes both sides. To do the same here, TernaryHandler has to emit a new virtual node, and Turbo shadows it, so it also needs a C++ port.

Impure points alone are not enough. A possibly undefined offset and a property fetch on a nullable have none, but both give a warning. So the rule would still need some checks on top, like NullCoalesceRule has.

About the speed: on self-analysis the rule checked 2023 ternaries in 66 ms in total, out of 141 s of CPU. The clone ran 500 times and took 5.6 ms of that.

Comment on lines +276 to +293
if ($expr instanceof Expr\FuncCall) {
if (!$expr->name instanceof Node\Name || $expr->isFirstClassCallable()) {
return false;
}
if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) {
return false;
}
if (!$this->reflectionProvider->getFunction($expr->name, $scope)->hasSideEffects()->no()) {
return false;
}
foreach ($expr->getArgs() as $arg) {
if ($arg->unpack || $arg->byRef || !$this->isPure($arg->value, $scope)) {
return false;
}
}

return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we miss handling for methods?
pure callables?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, method calls, static calls and pure callables are not reported now. The rule skips them, so this is a missing report, not a wrong one.

The impure points from a virtual node would cover them. I checked this with NullCoalesceRule, which reports a pure method, a pure static method and a pure-callable on its right side. Do you want me to switch to that, with the C++ port? The smaller option is to add MethodCall and StaticCall to isPure() through hasSideEffects().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the rule now treats pure method calls, static calls and pure callables as pure. See 65ad8b0.

SanderMuller and others added 3 commits November 3, 2026 21:52
…rnary rule

The rule only accepted function calls without side effects, so it did
not report a ternary with a pure method, static method or callable call.
These calls also count for the check that a result with a call is not
evaluated in the scope of the other branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hasMethod() is only yes when every type of the receiver has the
method, so the canCallMethods() check before it changed nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ternary operator is unnecessary

2 participants