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
24 changes: 21 additions & 3 deletions bin/functionMetadata_original.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@
* keyed by lowercase function name or "Class::method". resources/functionMetadata.php
* is generated from this file by bin/generate-function-metadata.php.
*
* Each entry is exactly one of these shapes:
* Each entry has one of these shapes:
*
* - ['hasSideEffects' => bool]
* false: the call is pure. true: the call has side effects.
* - ['pureUnlessCallableIsImpureParameters' => array<string, true>]
* 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) optional parameters
* (keyed by parameter name) receives an argument, e.g. str_replace()
* whose only side effect is writing to its optional 'count' argument.
*
* The last two can be combined for a call that is pure unless either happens,
* e.g. preg_replace_callback() (impure callback or a passed '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>}|array{pureUnlessCallableIsImpureParameters: array<string, bool>, pureUnlessParameterPassedParameters: array<string, bool>}> */
return [
'abs' => ['hasSideEffects' => false],
'acos' => ['hasSideEffects' => false],
Expand Down Expand Up @@ -264,14 +271,25 @@
'output_reset_rewrite_vars' => ['hasSideEffects' => true],
'pclose' => ['hasSideEffects' => true],
'popen' => ['hasSideEffects' => true],
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
'preg_filter' => ['pureUnlessParameterPassedParameters' => ['count' => 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], 'pureUnlessParameterPassedParameters' => ['count' => 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
14 changes: 12 additions & 2 deletions 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>}|array{pureUnlessCallableIsImpureParameters: array<string, bool>, pureUnlessParameterPassedParameters: array<string, bool>}> */
return [
Comment thread
staabm marked this conversation as resolved.
'BackedEnum::from' => ['hasSideEffects' => false],
'BackedEnum::tryFrom' => ['hasSideEffects' => false],
Expand Down Expand Up @@ -1631,11 +1634,15 @@
'posix_ttyname' => ['hasSideEffects' => false],
'posix_uname' => ['hasSideEffects' => false],
'pow' => ['hasSideEffects' => false],
'preg_filter' => ['pureUnlessParameterPassedParameters' => ['count' => true]],
'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_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true]],
'preg_replace' => ['pureUnlessParameterPassedParameters' => ['count' => true]],
'preg_replace_callback' => ['pureUnlessCallableIsImpureParameters' => ['callback' => true], 'pureUnlessParameterPassedParameters' => ['count' => true]],
'preg_split' => ['hasSideEffects' => false],
'property_exists' => ['hasSideEffects' => false],
'quoted_printable_decode' => ['hasSideEffects' => false],
Expand Down Expand Up @@ -1666,6 +1673,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 +1688,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
9 changes: 9 additions & 0 deletions src/Analyser/ExprHandler/NewHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,22 @@ private function processConstructorReflection(string $className, New_ $expr, Mut
if ($constructorReflection !== null) {
if (!$constructorReflection->hasSideEffects()->no()) {
$certain = $constructorReflection->isPure()->no();
// A constructor can carry both flags at once, so combine the verdicts
// the same way SimpleImpurePoint::createFromVariant() does for calls:
// Yes = pure, No = impure, Maybe = possibly impure.
$verdict = SimpleImpurePoint::resolvePureUnlessCallableIsImpureVerdict($parametersAcceptor, $scope, $expr->getArgs());
$passedVerdict = SimpleImpurePoint::resolvePureUnlessParameterPassedVerdict($parametersAcceptor, $expr->getArgs());
if ($passedVerdict !== null) {
$verdict = $verdict === null ? $passedVerdict : $verdict->and($passedVerdict);
}

if ($verdict !== null && $verdict->yes()) {
return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints];
}
if ($verdict !== null && $verdict->no()) {
$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 @@ -1554,6 +1554,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 @@ -1576,6 +1577,7 @@ public function enterClassMethod(
bool $isConstructor = false,
?ResolvedPhpDocBlock $resolvedPhpDocBlock = null,
array $phpDocPureUnlessCallableIsImpureParameters = [],
array $phpDocPureUnlessParameterPassedParameters = [],
): self
{
if (!$this->isInClass()) {
Expand Down Expand Up @@ -1612,6 +1614,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 @@ -1702,6 +1705,7 @@ public function enterPropertyHook(
false,
$this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)),
[],
[],
),
true,
);
Expand Down Expand Up @@ -1779,6 +1783,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 @@ -1797,6 +1802,7 @@ public function enterFunction(
array $immediatelyInvokedCallableParameters = [],
array $phpDocClosureThisTypeParameters = [],
array $pureUnlessCallableIsImpureParameters = [],
array $pureUnlessParameterPassedParameters = [],
): self
{
return $this->enterFunctionLike(
Expand All @@ -1823,6 +1829,7 @@ public function enterFunction(
$phpDocClosureThisTypeParameters,
$this->attributeReflectionFactory->fromAttrGroups($function->attrGroups, InitializerExprContext::fromStubParameter(null, $this->getFile(), $function)),
$pureUnlessCallableIsImpureParameters,
$pureUnlessParameterPassedParameters,
),
false,
);
Expand Down
12 changes: 8 additions & 4 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ public function processStmtNode(
$throwPoints = [];
$impurePoints = [];
$this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $storage, $nodeCallback);
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, , $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts,, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters] = $this->getPhpDocs($scope, $stmt);
[$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, , $isPure, $acceptsNamedArguments, , $phpDocComment, $asserts,, $phpDocParameterOutTypes, , , , $pureUnlessCallableIsImpureParameters, $pureUnlessParameterPassedParameters] = $this->getPhpDocs($scope, $stmt);

foreach ($stmt->params as $param) {
$this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback);
Expand Down Expand Up @@ -796,6 +796,7 @@ public function processStmtNode(
$phpDocImmediatelyInvokedCallableParameters,
$phpDocClosureThisTypeParameters,
$pureUnlessCallableIsImpureParameters,
$pureUnlessParameterPassedParameters,
);
$functionReflection = $functionScope->getFunction();
if (!$functionReflection instanceof PhpFunctionFromParserNodeReflection) {
Expand Down Expand Up @@ -861,7 +862,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 @@ -899,6 +900,7 @@ public function processStmtNode(
$isConstructor,
null,
$pureUnlessCallableIsImpureParameters,
$pureUnlessParameterPassedParameters,
);

if (!$scope->isInClass()) {
Expand Down Expand Up @@ -4895,7 +4897,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 @@ -4926,6 +4928,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 @@ -5060,6 +5063,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 @@ -5083,7 +5087,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
Loading
Loading