Skip to content

Implement @pure-unless-callable-is-impure#3482

Open
zonuexe wants to merge 16 commits into
phpstan:2.2.xfrom
zonuexe:feature/pure-unless-callable-is-impure
Open

Implement @pure-unless-callable-is-impure#3482
zonuexe wants to merge 16 commits into
phpstan:2.2.xfrom
zonuexe:feature/pure-unless-callable-is-impure

Conversation

@zonuexe

@zonuexe zonuexe commented Sep 25, 2024

Copy link
Copy Markdown
Contributor

Implements the @pure-unless-callable-is-impure PHPDoc tag (parser support: phpstan/phpdoc-parser#253, merged): a parameter-level annotation declaring that the function/method is pure unless the callable passed to that parameter is impure. Rebuilt on top of 2.2.x.

How it works

  • The tag (and its @phpstan- prefixed alias) is resolved into per-parameter flags, threaded through the reflection layer (ExtendedParameterReflection::isPureUnlessCallableIsImpureParameter()), and populated for builtins via functionMetadata (array_filter, array_map, array_reduce, the array_*u* comparator family, call_user_func(_array), forward_static_call(_array), preg_replace_callback(_array), …) and for array_reduce via its stub.
  • SimpleImpurePoint::createFromVariant() evaluates the purity of arguments passed to flagged parameters:
    • provably pure callback (or omitted / null) → no impure point — the call is treated as pure;
    • provably impure callback → the impure point becomes certain;
    • unknown callable (opaque callable type) → unchanged possibly-impure behavior.
  • Since the no-effect detection (NoopExpressionNode) is driven by "the statement has zero impure points", standalone array_map($pureCb, $arr); statements are now reported by CallToFunctionStatementWithoutSideEffectsRule with no rule changes.
/** @phpstan-pure */
function doubled(array $arr): array
{
    return array_map(static fn (int $x): int => $x * 2, $arr); // no longer "Possibly impure"
}

array_map('is_string', $arr); // now: "Call to function array_map() on a separate line has no effect."
array_map(static function ($x) { echo $x; }, $arr); // still allowed as a statement (impure callback)
  • Add function map
  • Add tests (fail-first: PureFunctionRule/PureMethodRule regressions, CallToFunctionStatementWithoutSideEffectsRule cases incl. impure/opaque/by-ref non-regressions)
  • Add rules (no new rule needed — the impure-point suppression feeds both the pure-context check and the existing no-effect rule)

refs #3106, #5912

Closes phpstan/phpstan#11101
Closes phpstan/phpstan#11100

@zonuexe zonuexe marked this pull request as draft September 25, 2024 17:46
Comment thread src/PhpDoc/PhpDocNodeResolver.php Outdated

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.

Will be replaced by phpstan/phpdoc-parser#253

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.

WIP. = false will be removed.

@ondrejmirtes

Copy link
Copy Markdown
Member

Just updated phpdoc-parser here so you can use that.

@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch 4 times, most recently from eddd22a to f207c72 Compare September 30, 2024 18:09
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from f207c72 to 8c756cc Compare November 3, 2024 17:57
@staabm

staabm commented Nov 18, 2024

Copy link
Copy Markdown
Contributor

@zonuexe any plans to move this forward?

@zonuexe

zonuexe commented Nov 19, 2024

Copy link
Copy Markdown
Contributor Author

@staabm Hi, I know this branch is important but it was blocked due to my motivation. I'm going to rebase it on 2.0.x and do the necessary work. If I miss it tonight, I won't be able to start the rest of the work until next week, so it would be quicker for you to take over then.

@staabm

staabm commented Nov 19, 2024

Copy link
Copy Markdown
Contributor

I don't want to put pressure on you. It was not clear to me whether you stopped working on it or not.

I am totally fine if this takes a bit more time - thank you

@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from 8c756cc to 389c7f0 Compare November 19, 2024 16:58
@zonuexe

zonuexe commented Nov 19, 2024

Copy link
Copy Markdown
Contributor Author

No problem, this is a very interesting subject for me too, so I'm happy to work on it because I realized that there are other people who are interested in it. More importantly, I'm always helped by your hard work.

@zonuexe zonuexe changed the base branch from 1.12.x to 2.0.x November 19, 2024 17:11
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch 4 times, most recently from 2146ae0 to 1cc1fad Compare November 19, 2024 18:32
@VincentLanglet

Copy link
Copy Markdown
Contributor

Friendly ping @zonuexe, still interested in this PR ? Do you have time to finish it ?
Do you look for help ? What is missing ?

@zonuexe

zonuexe commented Jul 20, 2025

Copy link
Copy Markdown
Contributor Author

@VincentLanglet
Thank you for the suggestion, sorry for making you wait so long.
I've been working on PHPStan 2.1.18 a few times over the last few weeks, and just finished a PHPStan workshop at a local PHP conference, so I'll get back to that tomorrow.

@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from adb02ca to abc8cd8 Compare August 1, 2025 12:45
@zonuexe zonuexe changed the base branch from 2.0.x to 2.1.x August 1, 2025 12:46
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from abc8cd8 to c4999be Compare August 1, 2025 13:09
@VincentLanglet

Copy link
Copy Markdown
Contributor

Friendly ping @zonuexe

I don't want to put pressure on you. It's just not clear to me whether you stopped working on it or not.
What is missing ?

It will unblock the phpstan/phpdoc-parser#259 pr :)

@VincentLanglet

Copy link
Copy Markdown
Contributor

Maybe I'm wrong, but I start to think this annotation is useless
https://phpstan.org/r/d3af976b-17ef-4ca6-87cb-4a1325622cee

PHPStan already handle cb with immediately/later invoked callable, even for a pure function/method.

So we just need to declare array_map and so as pure.

@devbanana

Copy link
Copy Markdown
Contributor

i'm happy to help contribute towards a solution if it's decided which direction is best.

@VincentLanglet

Copy link
Copy Markdown
Contributor

Hi @devbanana feel free to work on this.

Laybe @zonuexe could explain what he had in mind ; or @ondrejmirtes will have some guidelines

@zonuexe zonuexe marked this pull request as ready for review July 3, 2026 08:48
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

@staabm staabm requested a review from VincentLanglet July 3, 2026 09:52

public function getPureUnlessCallableIsImpureParameters(): array
{
return [];

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.

Suggested change
return [];
return $this->reflection->getPureUnlessCallableIsImpureParameters();

(seems we miss a test)


public function getPureUnlessCallableIsImpureParameters(): array
{
return [];

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.

Suggested change
return [];
return $this->nativeMethodReflection->getPureUnlessCallableIsImpureParameters();


public function getPureUnlessCallableIsImpureParameters(): array
{
return [];

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.

hmm does this miss the impl?


public function getPureUnlessCallableIsImpureParameters(): array
{
return [];

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 about this?


public function getPureUnlessCallableIsImpureParameters(): array
{
return [];

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.

and here?

@zonuexe zonuexe marked this pull request as draft July 5, 2026 07:36
return TrinaryLogic::createYes();
}

public function getPureUnlessCallableIsImpureParameters(): array

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.

also needs tests with constructor

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.

Good catch. You were right that method support was entirely non-functional, not only those stubs. Two gaps caused it.

  • PhpClassReflectionExtension read the pure-unless map only from built-in method metadata, never from a userland method's own @pure-unless-callable-is-impure tag.
  • PhpMethodReflection::getParameters() did not pass the flag on to PhpParameterReflection, while the function path already did.

I fixed both and made the wrappers you pointed at delegate. I also added a method regression test with pure, impure and opaque callbacks, including a union-typed receiver.

@zonuexe

zonuexe commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on method support.

  • Method-level @pure-unless-callable-is-impure now works end to end, at parity with functions. Verified for direct, union-typed and intersection-typed receivers.
  • I audited every parameter-construction site for the same flag-dropping pattern. The others are correct, because the instanceof ExtendedParameterReflection ? ... : new ExtendedDummyParameter(...) wraps only ever see non-extended parameters, which cannot carry the flag.
  • Two paths stay un-threaded on purpose and now carry a comment: createNativeMethodVariant(), since no built-in method carries the metadata, and Closure::call()'s own parameters, since a closure cannot carry the tag.

@zonuexe zonuexe marked this pull request as ready for review July 5, 2026 08:13
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

zonuexe added 16 commits July 5, 2026 17:18
Resolve the tag into per-parameter flags and merge them across parent PHPDocs.
Add isPureUnlessCallableIsImpureParameter() on parameter reflections and getPureUnlessCallableIsImpureParameters() on function/method reflections, populated from PHPDoc and function metadata.
Return the parameter flags from getPhpDocs(), thread them through enterClassMethod(), and consult them when collecting impure points for calls.
array_filter/array_map/array_reduce, the array_u*/array_*_u* family, call_user_func(_array), forward_static_call(_array) and preg_replace_callback(_array) are pure unless their callback argument is impure.
Type-inference fixture plus function-metadata expectations.
The flag was silently dropped wherever parameters are re-wrapped (combineAcceptors, overrideParameterType, resolved template variants, unresolved method prototypes) and the BetterReflection function path never read the PHPDoc tag. Also honor the @phpstan- prefixed alias, strip the leading $ from the tag's parameter name, read the tag from stub PHPDocs, and annotate array_reduce's stub.
…acks are pure

SimpleImpurePoint::createFromVariant() now evaluates the purity of arguments passed to flagged parameters: provably pure (or omitted) callbacks produce no impure point, provably impure ones make the point certain, unknown callables keep the current possibly-impure behavior. This fixes the false positive for array_map() with a pure callback in @phpstan-pure functions and enables the no-effect report for standalone statements.

Closes phpstan/phpstan#11100
Closes phpstan/phpstan#11101
Named arguments are not supported by the PHP 7.4 downgrade, so pass the enterClassMethod() parameters positionally. The file-without-errors.php fixture contained a standalone array_filter() call that is now correctly reported as having no effect - wrap it in echo count().
…-callable arguments

Kills the two escaped Infection mutants in SimpleImpurePoint: a (callable|null) callback exercises the isNull() branch and a (pure-callable|int) callback exercises the isCallable() branch - both keep the call possibly impure.
Address review nits: use null coalescing for the array<string, bool> parameter flags, a ternary for the hasSideEffects TrinaryLogic (keeping the absent -> Maybe branch), and split the negated method hasSideEffects read into named steps matching the ancestors loop below.
The value shape gained a second form (pureUnlessCallableIsImpureParameters), so describe both forms in the file-level comments of the source, the generator template and the generated file, add an inline @var before the array, and fix the generator's now-stale internal @var.
PHP 8.4's array_all() and array_any() are pure unless their callback is impure, like array_filter()/array_map().
…tions

Generic builtins like array_find()/array_find_key() are reflected via BetterReflection (PhpFunctionReflection), whose getCustomFunction() only read the @pure-unless-callable-is-impure PHPDoc tag and ignored functionMetadata.php - unlike the native-signature path. Merge the metadata's pureUnlessCallableIsImpureParameters in there too (PHPDoc tag wins). This makes array_find() actually honor its existing metadata entry and lets array_find_key() be added.
RequiredPhpVersionCommentTest requires fixtures using version-specific features to declare a '// lint >= X.Y' comment so they are skipped on older PHP in CI.
Method support was entirely non-functional: PhpClassReflectionExtension only read the pure-unless map from built-in method metadata (never from a userland method's own PHPDoc tag), and PhpMethodReflection::getParameters() never passed the flag on to its PhpParameterReflection (the function path already did). Read the tag from the resolved PHPDoc and thread the flag through, and make the method-reflection wrappers (changed-type, closure-call, intersection, union, parser-node function) delegate getPureUnlessCallableIsImpureParameters() instead of returning [].
createNativeMethodVariant() (no built-in method carries the metadata) and Closure::call()'s parameters (a closure cannot carry the tag) leave the flag at its default; document why so a future maintainer knows it is deliberate, not an oversight.
@zonuexe zonuexe force-pushed the feature/pure-unless-callable-is-impure branch from 0188553 to c45ca89 Compare July 5, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

6 participants