Skip to content
16 changes: 15 additions & 1 deletion bin/functionMetadata_original.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@
* the call is pure unless one of the listed callable parameters
* (keyed by parameter name) receives an impure callable, e.g. array_map()
* whose only side effects come from its 'callback' argument.
* - ['pureUnlessParameterPassedParameters' => array<string, true>]
* the call is pure unless one of the listed (by-ref out) parameters
* (keyed by parameter name) receives an argument, e.g. str_replace()
* whose only side effect is writing to its optional 'count' argument.
*/

/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}> */
return [
'abs' => ['hasSideEffects' => false],
'acos' => ['hasSideEffects' => false],
Expand Down Expand Up @@ -264,14 +268,24 @@
'output_reset_rewrite_vars' => ['hasSideEffects' => true],
'pclose' => ['hasSideEffects' => true],
'popen' => ['hasSideEffects' => true],
// 'matches'/'subpatterns': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls
// back to the legacy functionMap.php name.
'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]],
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]],
'readfile' => ['hasSideEffects' => true],
'rename' => ['hasSideEffects' => true],
'rewind' => ['hasSideEffects' => true],
'rmdir' => ['hasSideEffects' => true],
'sprintf' => ['hasSideEffects' => false],
'str_decrement' => ['hasSideEffects' => false],
'str_increment' => ['hasSideEffects' => false],
// 'count'/'replace_count': PHP 8+ uses the php-8-stubs parameter name, PHP <8 falls
// back to the legacy functionMap.php name.
'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
'symlink' => ['hasSideEffects' => true],
'time' => ['hasSideEffects' => true],
'tempnam' => ['hasSideEffects' => true],
Expand Down
30 changes: 28 additions & 2 deletions bin/generate-function-metadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public function enterNode(Node $node)
);
}

/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>}> $metadata */
/** @var array<string, array{hasSideEffects?: bool, pureUnlessCallableIsImpureParameters?: array<string, bool>, pureUnlessParameterPassedParameters?: array<string, bool>}> $metadata */
$metadata = require __DIR__ . '/functionMetadata_original.php';
foreach ($visitor->functions as $functionName) {
if (array_key_exists($functionName, $metadata)) {
Expand All @@ -134,6 +134,14 @@ public function enterNode(Node $node)

continue;
}

if (isset($metadata[$functionName]['pureUnlessParameterPassedParameters'])) {
$metadata[$functionName] = [
'pureUnlessParameterPassedParameters' => $metadata[$functionName]['pureUnlessParameterPassedParameters'],
];

continue;
}
}
$metadata[$functionName] = ['hasSideEffects' => false];
}
Expand Down Expand Up @@ -192,9 +200,12 @@ public function enterNode(Node $node)
* - ['pureUnlessCallableIsImpureParameters' => array<string, true>] - pure unless
* one of the listed callable parameters (keyed by parameter name) receives an
* impure callable, e.g. array_map()'s 'callback'.
* - ['pureUnlessParameterPassedParameters' => array<string, true>] - pure unless
* one of the listed (by-ref out) parameters (keyed by parameter name) receives
* an argument, e.g. str_replace()'s 'replace_count'.
*/

/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}> */
return [
%s
];
Expand All @@ -216,6 +227,20 @@ public function enterNode(Node $node)
),
),
];
$encodePureUnlessParameterPassedParameters = static fn (array $meta) => [
$escape('pureUnlessParameterPassedParameters'),
sprintf(
'[%s]',
implode(
' ,',
array_map(
static fn ($key, $param) => sprintf('%s => %s', $escape($key), $escape($param)),
array_keys($meta['pureUnlessParameterPassedParameters']),
$meta['pureUnlessParameterPassedParameters'],
),
),
),
];

foreach ($metadata as $name => $meta) {
$content .= sprintf(
Expand All @@ -224,6 +249,7 @@ public function enterNode(Node $node)
...match (true) {
isset($meta['hasSideEffects']) => $encodeHasSideEffects($meta),
isset($meta['pureUnlessCallableIsImpureParameters']) => $encodePureUnlessCallableIsImpureParameters($meta),
isset($meta['pureUnlessParameterPassedParameters']) => $encodePureUnlessParameterPassedParameters($meta),
default => throw new ShouldNotHappenException($escape($meta)),
},
);
Expand Down
11 changes: 10 additions & 1 deletion resources/functionMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
* - ['pureUnlessCallableIsImpureParameters' => array<string, true>] - pure unless
* one of the listed callable parameters (keyed by parameter name) receives an
* impure callable, e.g. array_map()'s 'callback'.
* - ['pureUnlessParameterPassedParameters' => array<string, true>] - pure unless
* one of the listed (by-ref out) parameters (keyed by parameter name) receives
* an argument, e.g. str_replace()'s 'count'.
*/

/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}> */
/** @var array<string, array{hasSideEffects: bool}|array{pureUnlessCallableIsImpureParameters: array<string, bool>}|array{pureUnlessParameterPassedParameters: array<string, bool>}> */
return [
'BackedEnum::from' => ['hasSideEffects' => false],
'BackedEnum::tryFrom' => ['hasSideEffects' => false],
Expand Down Expand Up @@ -1634,7 +1637,10 @@
'preg_grep' => ['hasSideEffects' => false],
'preg_last_error' => ['hasSideEffects' => true],
'preg_last_error_msg' => ['hasSideEffects' => true],
'preg_match' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
'preg_match_all' => ['pureUnlessParameterPassedParameters' => ['matches' => true, 'subpatterns' => true]],
'preg_quote' => ['hasSideEffects' => false],
'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]],
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
'preg_split' => ['hasSideEffects' => false],
'property_exists' => ['hasSideEffects' => false],
Expand Down Expand Up @@ -1666,6 +1672,7 @@
'rtrim' => ['hasSideEffects' => false],
'sha1' => ['hasSideEffects' => false],
'sha1_file' => ['hasSideEffects' => true],
'similar_text' => ['pureUnlessParameterPassedParameters' => ['percent' => true]],
'sin' => ['hasSideEffects' => false],
'sinh' => ['hasSideEffects' => false],
'sizeof' => ['hasSideEffects' => false],
Expand All @@ -1680,8 +1687,10 @@
'str_ends_with' => ['hasSideEffects' => false],
'str_getcsv' => ['hasSideEffects' => false],
'str_increment' => ['hasSideEffects' => false],
'str_ireplace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
'str_pad' => ['hasSideEffects' => false],
'str_repeat' => ['hasSideEffects' => false],
'str_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true, 'replace_count' => true]],
'str_rot13' => ['hasSideEffects' => false],
'str_split' => ['hasSideEffects' => false],
'str_starts_with' => ['hasSideEffects' => false],
Expand Down
11 changes: 11 additions & 0 deletions src/Analyser/ExprHandler/NewHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,17 @@
if ($verdict !== null && $verdict->no()) {
$certain = true;
}

if (!$certain) {
$passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs());
if ($passedVerdict !== null && $passedVerdict->yes()) {

Check warning on line 272 in src/Analyser/ExprHandler/NewHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if (!$certain) { $passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs()); - if ($passedVerdict !== null && $passedVerdict->yes()) { + if ($passedVerdict !== null && !$passedVerdict->no()) { return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; } if ($passedVerdict !== null && $passedVerdict->no()) {
return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints];
}
if ($passedVerdict !== null && $passedVerdict->no()) {

Check warning on line 275 in src/Analyser/ExprHandler/NewHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ($passedVerdict !== null && $passedVerdict->yes()) { return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; } - if ($passedVerdict !== null && $passedVerdict->no()) { + if ($passedVerdict !== null && !$passedVerdict->yes()) { $certain = true; } }
$certain = true;
}
}

$impurePoints[] = new ImpurePoint(
$scope,
$expr,
Expand Down
7 changes: 7 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,7 @@ public function enterTrait(ClassReflection $traitReflection): self
* @param array<string, bool> $immediatelyInvokedCallableParameters
* @param array<string, Type> $phpDocClosureThisTypeParameters
* @param array<string, bool> $phpDocPureUnlessCallableIsImpureParameters
* @param array<string, bool> $phpDocPureUnlessParameterPassedParameters
*/
public function enterClassMethod(
Node\Stmt\ClassMethod $classMethod,
Expand All @@ -1578,6 +1579,7 @@ public function enterClassMethod(
bool $isConstructor = false,
?ResolvedPhpDocBlock $resolvedPhpDocBlock = null,
array $phpDocPureUnlessCallableIsImpureParameters = [],
array $phpDocPureUnlessParameterPassedParameters = [],
): self
{
if (!$this->isInClass()) {
Expand Down Expand Up @@ -1614,6 +1616,7 @@ public function enterClassMethod(
$isConstructor,
$this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)),
$phpDocPureUnlessCallableIsImpureParameters,
$phpDocPureUnlessParameterPassedParameters,
),
!$classMethod->isStatic(),
);
Expand Down Expand Up @@ -1704,6 +1707,7 @@ public function enterPropertyHook(
false,
$this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)),
[],
[],
),
true,
);
Expand Down Expand Up @@ -1781,6 +1785,7 @@ private function getParameterAttributes(ClassMethod|Function_|PropertyHook $func
* @param array<string, bool> $immediatelyInvokedCallableParameters
* @param array<string, Type> $phpDocClosureThisTypeParameters
* @param array<string, bool> $pureUnlessCallableIsImpureParameters
* @param array<string, bool> $pureUnlessParameterPassedParameters
*/
public function enterFunction(
Node\Stmt\Function_ $function,
Expand All @@ -1799,6 +1804,7 @@ public function enterFunction(
array $immediatelyInvokedCallableParameters = [],
array $phpDocClosureThisTypeParameters = [],
array $pureUnlessCallableIsImpureParameters = [],
array $pureUnlessParameterPassedParameters = [],
): self
{
return $this->enterFunctionLike(
Expand All @@ -1825,6 +1831,7 @@ public function enterFunction(
$phpDocClosureThisTypeParameters,
$this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)),
$pureUnlessCallableIsImpureParameters,
$pureUnlessParameterPassedParameters,
),
false,
);
Expand Down
9 changes: 6 additions & 3 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ public function processStmtNode(
$throwPoints = [];
$impurePoints = [];
$this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback);
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt);
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $phpDocComment, $asserts, $selfOutType, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters] = $this->getPhpDocs($scope, $stmt);

foreach ($stmt->params as $param) {
$this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback);
Expand Down Expand Up @@ -913,6 +913,7 @@ public function processStmtNode(
$isConstructor,
null,
$pureUnlessCallableIsImpureParameters,
$pureUnlessParameterPassedParameters,
);

if (!$scope->isInClass()) {
Expand Down Expand Up @@ -4943,7 +4944,7 @@ private function processNodesForCalledMethod($node, ExpressionResultStorage $sto
}

/**
* @return array{TemplateTypeMap, array<string, Type>, array<string, bool>, array<string, Type>, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array<string, Type>, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array<string, bool>}
* @return array{TemplateTypeMap, array<string, Type>, array<string, bool>, array<string, Type>, ?Type, ?Type, ?string, bool, bool, bool, bool|null, bool, bool, string|null, Assertions, ?Type, array<string, Type>, array<(string|int), VarTag>, bool, ?ResolvedPhpDocBlock, array<string, bool>, array<string, bool>}
*/
public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $node): array
{
Expand Down Expand Up @@ -4974,6 +4975,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n
$functionName = null;
$phpDocParameterOutTypes = [];
$phpDocPureUnlessCallableIsImpureParameters = [];
$phpDocPureUnlessParameterPassedParameters = [];

if ($node instanceof Node\Stmt\ClassMethod) {
if (!$scope->isInClass()) {
Expand Down Expand Up @@ -5108,6 +5110,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n
$selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null;
$varTags = $resolvedPhpDoc->getVarTags();
$phpDocPureUnlessCallableIsImpureParameters = $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure();
$phpDocPureUnlessParameterPassedParameters = $resolvedPhpDoc->getParamsPureUnlessParameterPassed();
}

if ($acceptsNamedArguments && $scope->isInClass()) {
Expand All @@ -5131,7 +5134,7 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n
}
}

return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters];
return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation, $resolvedPhpDoc, $phpDocPureUnlessCallableIsImpureParameters, $phpDocPureUnlessParameterPassedParameters];
}

private function transformStaticType(ClassReflection $declaringClass, Type $type): Type
Expand Down
16 changes: 16 additions & 0 deletions src/PhpDoc/PhpDocNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,22 @@ public function resolveParamPureUnlessCallableIsImpure(PhpDocNode $phpDocNode):
return $parameters;
}

/**
* @return array<string, bool>
*/
public function resolveParamPureUnlessParameterPassed(PhpDocNode $phpDocNode): array
{
$parameters = [];
foreach (['@pure-unless-parameter-passed', '@phpstan-pure-unless-parameter-passed'] as $tagName) {
foreach ($phpDocNode->getPureUnlessParameterIsPassedTagValues($tagName) as $tag) {
$parameterName = substr($tag->parameterName, 1);
$parameters[$parameterName] = true;
}
}

return $parameters;
}

/**
* @return array<string, ParamClosureThisTag>
*/
Expand Down
45 changes: 45 additions & 0 deletions src/PhpDoc/ResolvedPhpDocBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ final class ResolvedPhpDocBlock
/** @var array<string, bool>|false */
private array|false $paramsPureUnlessCallableIsImpure = false;

/** @var array<string, bool>|false */
private array|false $paramsPureUnlessParameterPassed = false;

/** @var array<string, ParamClosureThisTag>|false */
private array|false $paramClosureThisTags = false;

Expand Down Expand Up @@ -224,6 +227,7 @@ public static function createEmpty(): self
$self->paramOutTags = [];
$self->paramsImmediatelyInvokedCallable = [];
$self->paramsPureUnlessCallableIsImpure = [];
$self->paramsPureUnlessParameterPassed = [];
$self->paramClosureThisTags = [];
$self->returnTag = null;
$self->throwsTag = null;
Expand Down Expand Up @@ -281,6 +285,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi
$result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parent, $parameterMapping, $parentClass);
$result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parent, $parameterMapping);
$result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping);
$result->paramsPureUnlessParameterPassed = self::mergeParamsPureUnlessParameterPassed($this->getParamsPureUnlessParameterPassed(), $parent, $parameterMapping);
$result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass);
$result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass);
$result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent);
Expand Down Expand Up @@ -601,6 +606,18 @@ public function getParamsPureUnlessCallableIsImpure(): array
return $this->paramsPureUnlessCallableIsImpure;
}

/**
* @return array<string, bool>
*/
public function getParamsPureUnlessParameterPassed(): array
{
if ($this->paramsPureUnlessParameterPassed === false) {
$this->paramsPureUnlessParameterPassed = $this->phpDocNodeResolver->resolveParamPureUnlessParameterPassed($this->phpDocNode);
}

return $this->paramsPureUnlessParameterPassed;
}

/**
* @return array<string, ParamClosureThisTag>
*/
Expand Down Expand Up @@ -1130,6 +1147,34 @@ private static function mergeOneParentParamPureUnlessCallableIsImpure(array $par
return $paramsPureUnlessCallableIsImpure;
}

/**
* @param array<string, bool> $paramsPureUnlessParameterPassed
* @return array<string, bool>
*/
private static function mergeParamsPureUnlessParameterPassed(array $paramsPureUnlessParameterPassed, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array
{
return self::mergeOneParentParamPureUnlessParameterPassed($paramsPureUnlessParameterPassed, $parent, $parameterMapping);
}

/**
* @param array<string, bool> $paramsPureUnlessParameterPassed
* @return array<string, bool>
*/
private static function mergeOneParentParamPureUnlessParameterPassed(array $paramsPureUnlessParameterPassed, self $parent, InheritedPhpDocParameterMapping $parameterMapping): array
{
$parentPureUnlessParameterPassed = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamsPureUnlessParameterPassed());

foreach ($parentPureUnlessParameterPassed as $name => $parentIsPureUnlessParameterPassed) {
if (array_key_exists($name, $paramsPureUnlessParameterPassed)) {
continue;
}

$paramsPureUnlessParameterPassed[$name] = $parentIsPureUnlessParameterPassed;
}

return $paramsPureUnlessParameterPassed;
}

/**
* @param array<string, ParamClosureThisTag> $paramsClosureThisTags
* @return array<string, ParamClosureThisTag>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,9 @@ public function isPureUnlessCallableIsImpureParameter(): TrinaryLogic
return TrinaryLogic::createNo();
}

public function isPureUnlessParameterPassedParameter(): TrinaryLogic
{
return TrinaryLogic::createNo();
}

}
Loading
Loading