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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ tests:
.PHONY: lint
lint:
php vendor/bin/parallel-lint --colors -e php,php3,php4,php5,phtml,phpt,fixed \
--exclude tests/Rules/StrictCalls/data/strict-function-calls-positional-before-named.php \
--exclude tests/Rules/StrictCalls/data/strict-function-calls-positional-before-named.php.fixed \
src tests

.PHONY: cs-install
Expand Down
166 changes: 158 additions & 8 deletions src/Rules/StrictCalls/StrictFunctionCallsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@
namespace PHPStan\Rules\StrictCalls;

use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ParametersAcceptor;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Type;
use function array_key_exists;
use function array_search;
use function in_array;
use function sprintf;
use function strtolower;

Expand Down Expand Up @@ -47,6 +56,8 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

$originalNode = $node;

if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
return [];
}
Expand All @@ -69,26 +80,165 @@ public function processNode(Node $node, Scope $scope): array
$argumentPosition = $this->functionArguments[$functionName];
if (!array_key_exists($argumentPosition, $node->getArgs())) {
return [
RuleErrorBuilder::message(sprintf(
'Call to function %s() requires parameter #%d to be set.',
$this->buildError(
sprintf(
'Call to function %s() requires parameter #%d to be set.',
$functionName,
$argumentPosition + 1,
),
$functionName,
$argumentPosition + 1,
))->identifier('function.strict')->build(),
$originalNode,
$node,
$scope,
$parametersAcceptor,
$argumentPosition,
),
];
}

$argumentType = $scope->getType($node->getArgs()[$argumentPosition]->value);
if (!$argumentType->isTrue()->yes()) {
return [
RuleErrorBuilder::message(sprintf(
'Call to function %s() requires parameter #%d to be true.',
$this->buildError(
sprintf(
'Call to function %s() requires parameter #%d to be true.',
$functionName,
$argumentPosition + 1,
),
$functionName,
$argumentPosition + 1,
))->identifier('function.strict')->build(),
$originalNode,
$node,
$scope,
$parametersAcceptor,
$argumentPosition,
),
];
}

return [];
}

private function buildError(
string $message,
string $functionName,
FuncCall $originalNode,
FuncCall $normalizedNode,
Scope $scope,
ParametersAcceptor $parametersAcceptor,
int $strictArgumentPosition
): IdentifierRuleError
{
$errorBuilder = RuleErrorBuilder::message($message)->identifier('function.strict');
if (
($functionName === 'in_array' || $functionName === 'array_search' || $functionName === 'array_keys')
&& $this->hasSafeStrictComparisonTypes($functionName, $normalizedNode, $scope)
) {
$namedArgumentPositions = [];
foreach ($parametersAcceptor->getParameters() as $position => $parameter) {
$namedArgumentPositions[$parameter->getName()] = $position;
}

$errorBuilder->fixNode($originalNode, static function (FuncCall $call) use ($namedArgumentPositions, $strictArgumentPosition): FuncCall {
$strictArgument = null;
$hasNamedArguments = false;
$usedPositions = [];
foreach ($call->getArgs() as $arg) {
if ($arg->unpack) {
// unpack is not fixable
return $call;
}

if ($arg->name !== null) {
$hasNamedArguments = true;
$argumentName = $arg->name->toString();
if (!array_key_exists($argumentName, $namedArgumentPositions)) {
return $call;
}

$argumentPosition = $namedArgumentPositions[$argumentName];
$usedPositions[$argumentPosition] = true;
if ($argumentPosition === $strictArgumentPosition) {
$strictArgument = $arg;
}
continue;
}

$argumentPosition = 0;
while (array_key_exists($argumentPosition, $usedPositions)) {
$argumentPosition++;
}
$usedPositions[$argumentPosition] = true;
if ($argumentPosition !== $strictArgumentPosition) {
continue;
}

$strictArgument = $arg;
}

$true = new ConstFetch(new Name('true'));
if ($strictArgument !== null) {
if (
$strictArgument->value instanceof ConstFetch
) {
if (!in_array($strictArgument->value->name->toLowerString(), ['true', 'false', 'null'], true)) {
return $call;
}
} elseif (
!$strictArgument->value instanceof Scalar\Int_
&& !$strictArgument->value instanceof Scalar\String_
) {
return $call;
}

$strictArgument->value = $true;
Comment thread
Copilot marked this conversation as resolved.
Resolved
return $call;
}

$targetArgumentName = array_search($strictArgumentPosition, $namedArgumentPositions, true);

$call->args[] = new Arg(
$true,
false,
false,
[],
$hasNamedArguments && $targetArgumentName !== false ? new Identifier($targetArgumentName) : null,
);

return $call;
});
}

return $errorBuilder->build();
}

private function hasSafeStrictComparisonTypes(string $functionName, FuncCall $normalizedNode, Scope $scope): bool
{
$args = $normalizedNode->getArgs();
if (!isset($args[0], $args[1])) {
return false;
}

$needleArgument = $functionName === 'array_keys' ? $args[1] : $args[0];
$haystackArgument = $functionName === 'array_keys' ? $args[0] : $args[1];
$needleType = $scope->getNativeType($needleArgument->value);
$haystackValueType = $scope->getNativeType($haystackArgument->value)->getIterableValueType();

return $this->areSameStrictScalarTypes($needleType, $haystackValueType);
}

private function areSameStrictScalarTypes(Type $needleType, Type $haystackValueType): bool
{
return (
$needleType->isBoolean()->yes()
&& $haystackValueType->isBoolean()->yes()
) || (
$needleType->isInteger()->yes()
&& $haystackValueType->isInteger()->yes()
) || (
$needleType->isString()->yes()
&& $haystackValueType->isString()->yes()
&& $haystackValueType->isNumericString()->no()
);
}
Comment on lines +229 to +242

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.

only fixing the most valuable and least risky for now


}
28 changes: 28 additions & 0 deletions tests/Rules/StrictCalls/StrictFunctionCallsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,34 @@ public function testRule(): void
]);
}

public function testFix(): void
{
$this->fix(
__DIR__ . '/data/strict-function-calls-fixable.php',
__DIR__ . '/data/strict-function-calls-fixable.php.fixed',
);
}

public function testFixNamedArguments(): void
{
if (PHP_VERSION_ID < 80000) {
self::markTestSkipped('Test requires PHP 8.0.');
}

$this->fix(
__DIR__ . '/data/strict-function-calls-named-arguments.php',
__DIR__ . '/data/strict-function-calls-named-arguments.php.fixed',
);
}

public function testFixPositionalBeforeNamed(): void
{
$this->fix(
__DIR__ . '/data/strict-function-calls-positional-before-named.php',
__DIR__ . '/data/strict-function-calls-positional-before-named.php.fixed',
);
}

public function testBug231(): void
{
if (PHP_VERSION_ID < 80100) {
Expand Down
97 changes: 97 additions & 0 deletions tests/Rules/StrictCalls/data/strict-function-calls-fixable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace StrictCalls;

function searchBool(bool $bool) {
array_search(false, [$bool]);
}

in_array(1, [1, 2]);
in_array(1, [1, 2], false);
array_keys([1, 2], 1);
$strict = rand(0, 1) === 1;
array_keys([1, 2], 1, $strict);
in_array(1, [1.0]);
in_array(1, [1.0], false);
in_array(1, [1, 1.0]);
in_array('1', ['01']);

in_array(1, [1, 2], 1);
in_array(1, [1, 2], 0);
in_array(1, [1, 2], "1");
in_array(1, [1, 2], "0");

$float = 1.0;
in_array($float, [1.0]);
in_array(null, [null]);

/** @return int[] */
function getArray(): array
{
return [1, 2];
}

function doFoo(int $i): bool
{
return in_array($i, getArray());
}

array_keys([1, 2]);
base64_decode('abcd');

$numericStringNeedle = '01'; // edge case: numeric string compared against int-string: https://3v4l.org/hmgdd#v
in_array($numericStringNeedle, ['1', '2']);

$stringNeedle = 'foo';
$strings = ['foo', 'bar'];
in_array($stringNeedle, $strings);
in_array($stringNeedle, $strings, false);
array_search($stringNeedle, $strings);
array_keys($strings, $stringNeedle);

/** @return string[] */
function getStringArray(): array
{
return ['foo', 'bar'];
}

function doStringFoo(string $needle): bool
{
return in_array($needle, getStringArray());
}

function doLiteralStringFoo(string $needle): bool
{
return in_array($needle, ['foo', 'bar']);
}

class X {
private const TYPES = [128, 256];
private const TYPE_STRINGS = ['ABC', 'DEF'];
private const TYPES_NUMERIC_STRING = ['ABC', 'DEF', '1'];

function doFoo(int $i, string $s) {
in_array($i, self::TYPES);
in_array($s, self::TYPE_STRINGS);
in_array($s, self::TYPES_NUMERIC_STRING);
}
}

class SideEffectProperty {

public function __get(string $name): bool
{
// some side-effect
echo 'side effect';

return false;
}

}

$sideEffectProperty = new SideEffectProperty();
// the "strict" argument is an interpolated string whose embedded expression
// triggers a magic __get() with a side-effect; it must not be overwritten
// with a plain "true" because that would silently drop the side-effect.
in_array(1, [1], "{$sideEffectProperty->strict}");

Loading
Loading