Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ parameters:
checkDynamicConstantNameValues: true
unusedLabel: true
newOnNonObject: true
reportMixedTernaryAndCoalesce: true
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ parameters:
checkDynamicConstantNameValues: false
unusedLabel: false
newOnNonObject: false
reportMixedTernaryAndCoalesce: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ parametersSchema:
checkDynamicConstantNameValues: bool()
unusedLabel: bool()
newOnNonObject: bool()
reportMixedTernaryAndCoalesce: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
73 changes: 73 additions & 0 deletions src/Rules/FunctionCallParametersCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use PHPStan\Type\ErrorType;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
Expand All @@ -35,6 +36,7 @@
use function array_fill;
use function array_key_exists;
use function array_last;
use function array_map;
use function array_merge;
use function count;
use function implode;
Expand Down Expand Up @@ -63,6 +65,8 @@ public function __construct(
private bool $checkExtraArguments,
#[AutowiredParameter]
private bool $checkMissingTypehints,
#[AutowiredParameter(ref: '%featureToggles.reportMixedTernaryAndCoalesce%')]
private bool $reportMixedTernaryAndCoalesce,
)
{
}
Expand Down Expand Up @@ -412,6 +416,29 @@ public function check(
->line($argumentLine)
->acceptsReasonsTip($accepts->reasons)
->build();
} elseif (
$this->reportMixedTernaryAndCoalesce
&& ($argumentValue instanceof Expr\Ternary || $argumentValue instanceof Expr\BinaryOp\Coalesce)
&& $argumentValueType instanceof MixedType
) {
foreach ($this->getInlineBranchTypes($argumentValue, $scope) as $branchType) {
$branchAccepts = $this->ruleLevelHelper->accepts($parameterType, $branchType, $isStrictTypes);
if ($branchAccepts->result) {
continue;
}

$verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $branchType);
$errors[] = RuleErrorBuilder::message(sprintf(
$wrongArgumentTypeMessage,
$this->describeParameter($parameter, $argumentName ?? $i + 1),
$parameterType->describe($verbosityLevel),
$branchType->describe($verbosityLevel),
))
->identifier('argument.type')
->line($argumentLine)
->acceptsReasonsTip($branchAccepts->reasons)
->build();
}
}
}

Expand Down Expand Up @@ -801,6 +828,52 @@ private function describeParameter(ParameterReflection $parameter, int|string|nu
return implode(' ', $parts);
}

/**
* Collects the leaf types an inline ternary (`? :`, `?:`) or null-coalesce (`??`)
* expression can produce, each resolved in the scope narrowed by the controlling
* condition. Nested ternaries and coalesces (including mixed nesting) are flattened
* so every value the expression can produce is represented by its own
* (un-normalized) type. Plain leaf expressions resolve to their scope type.
*
* The ternary else branch is narrowed by the negated condition (`filterByTruthyValue`
* of `!cond`) rather than `filterByFalseyValue($cond)`, mirroring how
* TernaryHandler::specifyTypes models the else branch. Some conditions (e.g.
* `is_resource()`, whose stub only declares `@phpstan-assert-if-true`) narrow
* asymmetrically, so the falsey scope would otherwise diverge from the type the
* ternary actually produces and report spurious branch types.
*
* The coalesce left operand contributes its non-null type, since `$a ?? $b` only
* yields `$a` when it is set and not null.
*
* @return list<Type>
*/
private function getInlineBranchTypes(Expr $expr, Scope $scope): array
{
if ($expr instanceof Expr\Ternary) {
$truthyScope = $scope->filterByTruthyValue($expr->cond);
$falseyScope = $scope->filterByTruthyValue(new Expr\BooleanNot($expr->cond));

if ($expr->if === null) {
$ifTypes = [TypeCombinator::removeFalsey($truthyScope->getType($expr->cond))];
} else {
$ifTypes = $this->getInlineBranchTypes($expr->if, $truthyScope);
}

return array_merge($ifTypes, $this->getInlineBranchTypes($expr->else, $falseyScope));
}

if ($expr instanceof Expr\BinaryOp\Coalesce) {
$leftTypes = array_map(
static fn (Type $type): Type => TypeCombinator::removeNull($type),
$this->getInlineBranchTypes($expr->left, $scope),
);

return array_merge($leftTypes, $this->getInlineBranchTypes($expr->right, $scope));
}

return [$scope->getType($expr)];
}

/**
* @return list<ConstantReflection>|null Null when the expression is not a constant or bitmask of constants
*/
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Analyser/Bug9307CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
);
}
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Rules/Classes/ClassAttributesRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: true),
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Rules/Classes/InstantiationRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Rules/Functions/Bug14844Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
);
}
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
$ruleLevelHelper,
reportMaybes: true,
Expand Down
32 changes: 32 additions & 0 deletions tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: true,
),
);
}
Expand Down Expand Up @@ -2978,4 +2979,35 @@ public function testBug11494(): void
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug11281(): void
{
$this->analyse([__DIR__ . '/data/bug-11281.php'], [
[
'Parameter #1 $i of function Bug11281Functions\takesInt expects int, string given.',
18,
],
[
'Parameter #1 $i of function Bug11281Functions\takesInt expects int, string given.',
35,
],
[
'Parameter #1 $i of function Bug11281Functions\takesInt expects int, string given.',
42,
],
[
'Parameter #1 $i of function Bug11281Functions\takesInt expects int, string given.',
52,
],
[
'Parameter #1 $i of function Bug11281Functions\takesInt expects int, string given.',
70,
],
[
'Parameter #1 $s of function Bug11281Functions\expectsString expects string, string|false given.',
99,
],
]);
}

}
1 change: 1 addition & 0 deletions tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Rules/Functions/ParamAttributesRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function getRule(): Rule
checkArgumentsPassedByReference: true,
checkExtraArguments: true,
checkMissingTypehints: true,
reportMixedTernaryAndCoalesce: false,
),
new ClassNameCheck(
new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: false),
Expand Down
114 changes: 114 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-11281.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug11281Functions;

function takesInt(int $i): void
{
}

/**
* @param array<string, mixed> $values
*/
function test(array $values): void
{
// The ternary's resulting type normalizes to mixed (mixed|string),
// but the else branch is definitely a string passed to an int parameter.
takesInt(array_key_exists('key', $values) ? $values['key'] : ' a string');
}

/**
* @param array<string, mixed> $values
*/
function noError(array $values): void
{
// Numeric-ish coercible branches must not be flagged.
takesInt(array_key_exists('key', $values) ? $values['key'] : 5);
}

/**
* @param array<string, mixed> $values
*/
function nested(array $values, bool $other, bool $another): void
{
takesInt($other ? $values['key'] : ($another ? 1 : ' nested string'));
}

function testShortTernary(mixed $mixed): void
{
// Short ternary (?:): the if-branch reuses the (truthy-narrowed) condition value,
// the else branch is a string passed to an int parameter, so it must be reported.
takesInt($mixed ?: ' a string');
}

/**
* @param array<string, mixed> $values
*/
function testCoalesce(array $values): void
{
// The coalesce's resulting type normalizes to mixed (mixed|string),
// but the else branch is definitely a string passed to an int parameter.
takesInt($values['key'] ?? ' a string');
}

/**
* @param array<string, mixed> $values
*/
function noErrorCoalesce(array $values): void
{
// Numeric-ish coercible branch must not be flagged.
takesInt($values['key'] ?? 5);
}

/**
* @param array<string, mixed> $values
* @param array<string, mixed> $other
*/
function nestedCoalesce(array $values, array $other): void
{
takesInt($values['key'] ?? $other['key'] ?? ' a string');
}

function expectsString(string $s): void
{
}

function benevolentBranchNotReported(mixed $value): void
{
// stream_get_contents() returns a *benevolent* string|false. PHPStan intentionally
// accepts benevolent unions for a string parameter, so the equivalent direct call
// expectsString(stream_get_contents($r)) is error-free too — reporting it here would
// re-introduce the pg_escape_bytea false positive this branch inspection guards
// against. is_resource() also narrows asymmetrically (@phpstan-assert-if-true only),
// so the else branch must keep the type the ternary actually produces (mixed,
// accepted) instead of a spurious narrowing. No error should be reported here.
expectsString(
is_resource($value)
? stream_get_contents($value)
Comment thread
staabm marked this conversation as resolved.
: $value,
Comment thread
staabm marked this conversation as resolved.
);
}

function strictFalseBranchReported(mixed $value, string|false $sf): void
{
// A *strict* (non-benevolent) string|false branch is not accepted by the string
// parameter, so branch inspection reports it even though is_resource() narrows
// asymmetrically and the ternary's resulting type normalizes to mixed.
expectsString(
is_resource($value)
? $sf
: $value,
);
}

function ternaryInVariable(mixed $value, string|false $sf): void
{
// no error because the ternary is not used inline as function argument,
// but stored in a variable, which kicks in type normalization.
$result = is_resource($value)
? $sf
: $value;

expectsString($result);
}
Loading
Loading