From 92e3f1eb4ee0d74b78234a62d77f30afeccfc5fc Mon Sep 17 00:00:00 2001 From: Alies Lapatsin <5278175+alies-dev@users.noreply.github.com> Date: Mon, 18 May 2026 18:23:54 +0200 Subject: [PATCH 1/4] Support @param-closure-this docblock tag Binds $this inside a closure or arrow-function argument to a caller-specified type. Mirrors PHPStan's @param-closure-this and is already shipped by Laravel (Macroable::macro, Manager::extend), Carbon, and Pest. Without this tag, closure bodies that reference $this trip InvalidScope even when the receiving method binds the closure via Closure::call() / Closure::bind(). Implementation: - Parse @param-closure-this, @psalm-param-closure-this, @phpstan-param-closure-this in the docblock parser; store on a new FunctionLikeParameter::$closure_this_type field next to $out_type. - At the call site, ArgumentsAnalyzer resolves the param's closure_this_type against self / static / template parameters (self resolves to the declaring class via declaring_method_ids so inherited static methods bind correctly; static resolves to the runtime called class) and stamps the resolved Union as the psalm-closure-this-type attribute on the Closure / ArrowFunction PHP-Parser node. - ClosureAnalyzer reads the attribute, overrides $this in the closure body use_context, and FunctionLikeAnalyzer calls setFQCLN on the inner StatementsAnalyzer so top-level closures pass the InvalidScope check. - Property fetches inside the bound closure resolve against the bound class storage rather than the caller's class. Closes #11851 --- docs/annotating_code/supported_annotations.md | 25 ++ src/Psalm/DocComment.php | 2 +- .../Internal/Analyzer/ClosureAnalyzer.php | 54 +++- .../Analyzer/FunctionLikeAnalyzer.php | 4 + .../Expression/Call/ArgumentsAnalyzer.php | 95 +++++++ .../Reflector/FunctionLikeDocblockParser.php | 50 ++++ .../Reflector/FunctionLikeDocblockScanner.php | 72 ++++++ src/Psalm/Internal/Scanner/DocblockParser.php | 10 + .../Scanner/FunctionDocblockComment.php | 5 + src/Psalm/Storage/FunctionLikeParameter.php | 6 +- tests/ParamClosureThisTest.php | 244 ++++++++++++++++++ 11 files changed, 553 insertions(+), 14 deletions(-) create mode 100644 tests/ParamClosureThisTest.php diff --git a/docs/annotating_code/supported_annotations.md b/docs/annotating_code/supported_annotations.md index 8ea1e83eace..b1168178caf 100644 --- a/docs/annotating_code/supported_annotations.md +++ b/docs/annotating_code/supported_annotations.md @@ -121,6 +121,31 @@ function addFoo(?string &$s) : void { } ``` +### `@param-closure-this`, `@psalm-param-closure-this` + +This is used to bind `$this` inside a `Closure` or arrow-function argument to a specific class type. Use it when the receiving function or method runs the callback with `Closure::bind` / `Closure::call` so that `$this` resolves to a different object than the caller's `$this`. The bound type may be a class name, `$this`, `static`, `self`, or a template parameter. + +```php +call($instance) + } +} + +class Builder extends Macroable { + public int $value = 42; +} + +Builder::macro('grab', function (): int { + // $this is Builder here, not the outer scope + return $this->value; +}); +``` + ### `@psalm-var`, `@psalm-param`, `@psalm-return`, `@psalm-property`, `@psalm-property-read`, `@psalm-property-write`, `@psalm-method` When specifying types in a format not supported by phpDocumentor ([but supported by Psalm](#type-syntax)) you may wish to prepend `@psalm-` to the PHPDoc tag, so as to avoid confusing your IDE. If a `@psalm`-prefixed tag is given, Psalm will use it in place of its non-prefixed counterpart. diff --git a/src/Psalm/DocComment.php b/src/Psalm/DocComment.php index 0d29ceb2b4c..28be2aef59a 100644 --- a/src/Psalm/DocComment.php +++ b/src/Psalm/DocComment.php @@ -34,7 +34,7 @@ final class DocComment 'allow-private-mutation', 'readonly-allow-private-mutation', 'yield', 'trace', 'import-type', 'flow', 'taint-specialize', 'taint-escape', 'taint-unescape', 'self-out', 'consistent-constructor', 'stub-override', - 'require-extends', 'require-implements', 'param-out', 'ignore-var', + 'require-extends', 'require-implements', 'param-out', 'param-closure-this', 'ignore-var', 'consistent-templates', 'if-this-is', 'this-out', 'check-type', 'check-type-exact', 'api', 'inheritors', ]; diff --git a/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php b/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php index 4b5f1d7918d..8ce08f70ec7 100644 --- a/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php @@ -33,6 +33,9 @@ final class ClosureAnalyzer extends FunctionLikeAnalyzer { use UnserializeMemoryUsageSuppressionTrait; + + public ?string $bound_this_class = null; + /** * @param PhpParser\Node\Expr\Closure|PhpParser\Node\Expr\ArrowFunction $function */ @@ -85,11 +88,30 @@ public static function analyzeExpression( return false; } - $use_context = new Context($context->self); + $bound_this_type = $stmt->getAttribute('psalm-closure-this-type'); + $bound_self = null; + + if ($bound_this_type instanceof Union) { + foreach ($bound_this_type->getAtomicTypes() as $bound_atomic) { + if ($bound_atomic instanceof TNamedObject) { + $bound_self = $bound_atomic->value; + break; + } + } + + $closure_analyzer->bound_this_class = $bound_self; + $use_context = new Context($bound_self ?? $context->self); + } else { + $use_context = new Context($context->self); + } $codebase = $statements_analyzer->getCodebase(); - if (!$statements_analyzer->isStatic() && !$closure_analyzer->isStatic()) { + if ($bound_this_type instanceof Union) { + if (!$closure_analyzer->isStatic()) { + $use_context->vars_in_scope['$this'] = $bound_this_type; + } + } elseif (!$statements_analyzer->isStatic() && !$closure_analyzer->isStatic()) { if ($context->collect_mutations && $context->self && $codebase->classExtends( @@ -106,27 +128,35 @@ public static function analyzeExpression( } } - foreach ($context->vars_in_scope as $var => $type) { - if (str_starts_with($var, '$this->')) { - $use_context->vars_in_scope[$var] = $type; + if ($bound_this_type === null) { + foreach ($context->vars_in_scope as $var => $type) { + if (str_starts_with($var, '$this->')) { + $use_context->vars_in_scope[$var] = $type; + } } } - if ($context->self) { - $self_class_storage = $codebase->classlike_storage_provider->get($context->self); + $properties_class = $bound_this_type instanceof Union + ? ($bound_self ?? null) + : $context->self; + + if ($properties_class !== null && $codebase->classlike_storage_provider->has($properties_class)) { + $self_class_storage = $codebase->classlike_storage_provider->get($properties_class); ClassAnalyzer::addContextProperties( $statements_analyzer, $self_class_storage, $use_context, - $context->self, - $statements_analyzer->getParentFQCLN(), + $properties_class, + $self_class_storage->parent_class, ); } - foreach ($context->vars_possibly_in_scope as $var => $_) { - if (str_starts_with($var, '$this->')) { - $use_context->vars_possibly_in_scope[$var] = true; + if ($bound_this_type === null) { + foreach ($context->vars_possibly_in_scope as $var => $_) { + if (str_starts_with($var, '$this->')) { + $use_context->vars_possibly_in_scope[$var] = true; + } } } diff --git a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php index f6f3fca83a1..4f18ab6d0bc 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php @@ -243,6 +243,10 @@ public function analyze( $statements_analyzer = new StatementsAnalyzer($this, $type_provider); + if ($this instanceof ClosureAnalyzer && $this->bound_this_class !== null) { + $statements_analyzer->setFQCLN($this->bound_this_class); + } + $byref_uses = []; if ($this instanceof ClosureAnalyzer && $this->function instanceof Closure) { foreach ($this->function->uses as $use) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 66ea59a9a8c..81ace1a2a69 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -59,6 +59,7 @@ use function array_values; use function assert; use function count; +use function explode; use function in_array; use function is_string; use function max; @@ -236,6 +237,21 @@ public static function analyze( ); } + if (($arg->value instanceof PhpParser\Node\Expr\Closure + || $arg->value instanceof PhpParser\Node\Expr\ArrowFunction) + && $param + && $param->closure_this_type + ) { + self::applyParamClosureThisHint( + $statements_analyzer, + $method_id, + $context, + $template_result ?? new TemplateResult([], []), + $arg, + $param, + ); + } + $was_inside_call = $context->inside_call; $context->inside_call = true; @@ -547,6 +563,85 @@ private static function handleClosureArg( } } + /** + * Resolves `@param-closure-this` against the call site and stamps the resolved type as + * a PHP-Parser node attribute on the Closure/ArrowFunction so ClosureAnalyzer can bind + * `$this` inside the closure body. + */ + private static function applyParamClosureThisHint( + StatementsAnalyzer $statements_analyzer, + ?string $method_id, + Context $context, + TemplateResult $template_result, + PhpParser\Node\Arg $arg, + FunctionLikeParameter $param, + ): void { + if (!$param->closure_this_type) { + return; + } + + $codebase = $statements_analyzer->getCodebase(); + + $self_fq_class_name = $context->self; + $static_fq_class_name = null; + + if ($method_id !== null && str_contains($method_id, '::')) { + [$called_class, $method_name] = explode('::', $method_id, 2); + $static_fq_class_name = $called_class; + $self_fq_class_name = $called_class; + + $method_name_lc = strtolower($method_name); + + if ($codebase->classlike_storage_provider->has($called_class)) { + $class_storage = $codebase->classlike_storage_provider->get($called_class); + + if (isset($class_storage->declaring_method_ids[$method_name_lc])) { + $self_fq_class_name = $class_storage->declaring_method_ids[$method_name_lc] + ->fq_class_name; + } + } + } + + $closure_this_type = $param->closure_this_type; + + if ($template_result->lower_bounds || $template_result->template_types) { + $closure_this_type = TemplateStandinTypeReplacer::replace( + $closure_this_type, + $template_result, + $codebase, + $statements_analyzer, + null, + null, + null, + $context->calling_method_id ?: $context->calling_function_id, + ); + + $closure_this_type = TemplateInferredTypeReplacer::replace( + $closure_this_type, + $template_result, + $codebase, + ); + } + + $static_type = $static_fq_class_name !== null + ? new TNamedObject($static_fq_class_name, true) + : null; + + $closure_this_type = TypeExpander::expandUnion( + $codebase, + $closure_this_type, + $self_fq_class_name, + $static_type, + null, + true, + false, + false, + true, + ); + + $arg->value->setAttribute('psalm-closure-this-type', $closure_this_type); + } + /** * @param list $args * @param array $function_params diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index e2a8e39e913..6e1ab75996c 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -191,6 +191,56 @@ public static function parse( } } + if (isset($parsed_docblock->combined_tags['param-closure-this'])) { + foreach ($parsed_docblock->combined_tags['param-closure-this'] as $offset => $param) { + $line_parts = CommentAnalyzer::splitDocLine($param); + + if (count($line_parts) === 1 && isset($line_parts[0][0]) && $line_parts[0][0] === '$') { + continue; + } + + if (count($line_parts) > 1) { + if (!preg_match('/\[[^\]]+\]/', $line_parts[0]) + && preg_match('/^(\.\.\.)?&?\$[A-Za-z0-9_]+,?$/', $line_parts[1]) + && $line_parts[0][0] !== '{' + ) { + if ($line_parts[1][0] === '&') { + $line_parts[1] = substr($line_parts[1], 1); + } + + $line_parts[0] = CommentAnalyzer::sanitizeDocblockType($line_parts[0]); + + if ($line_parts[0] === '' + || ($line_parts[0][0] === '$' + && !preg_match('/^\$this(\||$)/', $line_parts[0])) + ) { + throw new IncorrectDocblockException('Misplaced variable'); + } + + $line_parts[1] = (string) preg_replace('/,$/', '', $line_parts[1], 1); + + $info->params_closure_this[] = [ + 'name' => trim($line_parts[1]), + 'type' => str_replace("\n", '', $line_parts[0]), + 'line_number' => $comment->getStartLine() + substr_count( + $comment_text, + "\n", + 0, + $offset - $comment->getStartFilePos(), + ), + ]; + } + } else { + IssueBuffer::maybeAdd( + new InvalidDocblock( + 'Badly-formatted @param-closure-this in docblock for ' . $cased_function_id, + $code_location, + ), + ); + } + } + } + foreach (['psalm-self-out', 'psalm-this-out', 'phpstan-self-out', 'phpstan-this-out'] as $alias) { if (isset($parsed_docblock->tags[$alias])) { foreach ($parsed_docblock->tags[$alias] as $offset => $param) { diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php index 9408afed92d..500e9a92abf 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php @@ -316,6 +316,22 @@ public static function addDocblockInfo( ); } + foreach ($docblock_info->params_closure_this as $docblock_param_closure_this) { + self::handleParamClosureThis( + $docblock_param_closure_this, + $aliases, + $function_template_types, + $class_template_types, + $type_aliases, + $cased_function_id, + $file_scanner, + $stmt, + $storage, + $codebase, + $file_storage, + ); + } + if ($docblock_info->self_out && $storage instanceof MethodStorage) { $out_type = TypeParser::parseTokens( @@ -1424,6 +1440,62 @@ private static function handleParamOut( } } + /** + * @param array $type_aliases + * @param array> $function_template_types + * @param array> $class_template_types + * @param array{name:string, type:string, line_number: int} $docblock_param_closure_this + */ + private static function handleParamClosureThis( + array $docblock_param_closure_this, + Aliases $aliases, + array $function_template_types, + array $class_template_types, + array $type_aliases, + string $cased_function_id, + FileScanner $file_scanner, + PhpParser\Node\FunctionLike $stmt, + FunctionLikeStorage $storage, + Codebase $codebase, + FileStorage $file_storage, + ): void { + $param_name = substr($docblock_param_closure_this['name'], 1); + + try { + $closure_this_type = TypeParser::parseTokens( + TypeTokenizer::getFullyQualifiedTokens( + $docblock_param_closure_this['type'], + $aliases, + $function_template_types + $class_template_types, + $type_aliases, + ), + null, + $function_template_types + $class_template_types, + $type_aliases, + ); + } catch (TypeParseTreeException $e) { + $storage->docblock_issues[] = new InvalidDocblock( + $e->getMessage() . ' in docblock for ' . $cased_function_id, + new CodeLocation($file_scanner, $stmt, null, true), + ); + + return; + } + + /** @psalm-suppress UnusedMethodCall */ + $closure_this_type->queueClassLikesForScanning( + $codebase, + $file_storage, + $storage->template_types ?: [], + ); + + foreach ($storage->params as $param_storage) { + if ($param_storage->name === $param_name) { + $param_storage->closure_this_type = $closure_this_type; + } + } + } + /** * @param ?array> $template_types * @param array $type_aliases diff --git a/src/Psalm/Internal/Scanner/DocblockParser.php b/src/Psalm/Internal/Scanner/DocblockParser.php index 3161c0373af..85bb6428764 100644 --- a/src/Psalm/Internal/Scanner/DocblockParser.php +++ b/src/Psalm/Internal/Scanner/DocblockParser.php @@ -279,6 +279,16 @@ private static function resolveTags(ParsedDocblock $docblock): void + ($docblock->tags['phpstan-param-out'] ?? []) + ($docblock->tags['psalm-param-out'] ?? []); } + + if (isset($docblock->tags['param-closure-this']) + || isset($docblock->tags['psalm-param-closure-this']) + || isset($docblock->tags['phpstan-param-closure-this']) + ) { + $docblock->combined_tags['param-closure-this'] + = ($docblock->tags['param-closure-this'] ?? []) + + ($docblock->tags['phpstan-param-closure-this'] ?? []) + + ($docblock->tags['psalm-param-closure-this'] ?? []); + } } /** diff --git a/src/Psalm/Internal/Scanner/FunctionDocblockComment.php b/src/Psalm/Internal/Scanner/FunctionDocblockComment.php index 82d8d96f4a2..8c1286923ae 100644 --- a/src/Psalm/Internal/Scanner/FunctionDocblockComment.php +++ b/src/Psalm/Internal/Scanner/FunctionDocblockComment.php @@ -39,6 +39,11 @@ final class FunctionDocblockComment */ public array $params_out = []; + /** + * @var array + */ + public array $params_closure_this = []; + /** * @var array{type:string, line_number: int}|null */ diff --git a/src/Psalm/Storage/FunctionLikeParameter.php b/src/Psalm/Storage/FunctionLikeParameter.php index 8214dab83e8..91d6b84ec99 100644 --- a/src/Psalm/Storage/FunctionLikeParameter.php +++ b/src/Psalm/Storage/FunctionLikeParameter.php @@ -57,6 +57,7 @@ public function __construct( public bool $is_variadic = false, public Union|UnresolvedConstantComponent|null $default_type = null, public ?Union $out_type = null, + public ?Union $closure_this_type = null, ) { $this->signature_type_location = $type_location; } @@ -96,6 +97,9 @@ public function visit(TypeVisitor $visitor): bool if ($this->out_type && !$visitor->traverse($this->out_type)) { return false; } + if ($this->closure_this_type && !$visitor->traverse($this->closure_this_type)) { + return false; + } if ($this->default_type instanceof Union && !$visitor->traverse($this->default_type)) { return false; } @@ -109,7 +113,7 @@ public function visit(TypeVisitor $visitor): bool #[Override] public static function visitMutable(MutableTypeVisitor $visitor, &$node, bool $cloned): bool { - foreach (['type', 'signature_type', 'out_type', 'default_type'] as $key) { + foreach (['type', 'signature_type', 'out_type', 'closure_this_type', 'default_type'] as $key) { if (!$node->{$key} instanceof TypeNode) { continue; } diff --git a/tests/ParamClosureThisTest.php b/tests/ParamClosureThisTest.php new file mode 100644 index 00000000000..c2b937f0326 --- /dev/null +++ b/tests/ParamClosureThisTest.php @@ -0,0 +1,244 @@ + [ + 'code' => 'x + 1; } + } + + /** + * @param-closure-this Bound $callback + */ + function withBound(Closure $callback): void { + $callback->call(new Bound()); + } + + withBound(function (): int { + return $this->add() + $this->x; + }); + ', + ], + 'bindThisInArrowFunction' => [ + 'code' => 'call(new Bound()); + } + + withBound(fn (): int => $this->x); + ', + ], + 'bindStaticOnSelfReferencingMacroable' => [ + 'code' => 'value; + }); + ', + ], + 'bindThisAsLiteralThisToken' => [ + 'code' => 'call($this); + } + } + + $m = new Manager(); + $m->extend(function (): string { + return $this->name; + }); + ', + ], + 'phpstanAliasParseSameAsPsalm' => [ + 'code' => 'call(new Bound()); + } + + withBound(function (): int { + return $this->x; + }); + ', + ], + 'psalmAliasParseSameAsPlain' => [ + 'code' => 'call(new Bound()); + } + + withBound(function (): int { + return $this->x; + }); + ', + ], + 'selfResolvesToDeclaringClassOnInheritedStatic' => [ + 'code' => 'only_on_base; + }); + ', + ], + 'staticResolvesToCalledClassOnInheritedStatic' => [ + 'code' => 'only_on_child; + }); + ', + ], + 'bindInsideMethodOverridesCallerThis' => [ + 'code' => 'run(function (): int { + return $this->bound_prop; + }); + } + + /** + * @param-closure-this Bound $cb + */ + private function run(Closure $cb): void { + $cb->call(new Bound()); + } + } + ', + ], + ]; + } + + #[Override] + public function providerInvalidCodeParse(): iterable + { + return [ + 'callerPropertyNotVisibleInsideBoundClosure' => [ + 'code' => 'run(function (): int { + return $this->caller_prop; + }); + } + + /** + * @param-closure-this Bound $cb + */ + private function run(Closure $cb): void { + $cb->call(new Bound()); + } + } + ', + 'error_message' => 'UndefinedThisPropertyFetch', + ], + 'thisMethodNotVisibleOutsideBoundClass' => [ + 'code' => 'call(new Bound()); + } + + withBound(function (): int { + return $this->doesNotExist(); + }); + ', + 'error_message' => 'UndefinedMethod', + ], + ]; + } +} From 3624fa45ad11d0fe5c3752ce43bcff55e97368dd Mon Sep 17 00:00:00 2001 From: Alies Lapatsin <5278175+alies-dev@users.noreply.github.com> Date: Mon, 18 May 2026 22:19:00 +0200 Subject: [PATCH 2/4] Address psalm-expert review findings - TypeExpander now receives parent_class (from declaring class storage) and the called class's final flag, so @param-closure-this parent and final classes expand correctly. - TypeTokenizer in handleParamClosureThis now receives the declaring class's name and parent_class so compound expressions involving self/parent in the tag (e.g., self::TYPE_ALIAS) tokenize correctly during scanning. - TemplateStandinTypeReplacer now receives $context->self as $calling_class, matching the @param-out pattern. - ClosureAnalyzer validates that the bound class actually exists in storage before threading it through; an unknown class falls back to the unbound path instead of corrupting the closure's FQCLN. - addContextProperties keeps the pre-existing $statements_analyzer->getParentFQCLN() for unbound closures, isolating the bound-class parent_class lookup to the bound path. - Tests: cover parent, self with a class constant in scope, and the inheritance cases from the previous commit. --- .../Internal/Analyzer/ClosureAnalyzer.php | 25 ++++++++----- .../Expression/Call/ArgumentsAnalyzer.php | 24 +++++++++---- .../Reflector/FunctionLikeDocblockScanner.php | 4 +++ tests/ParamClosureThisTest.php | 36 +++++++++++++++++++ 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php b/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php index 8ce08f70ec7..9e229f17137 100644 --- a/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClosureAnalyzer.php @@ -88,24 +88,29 @@ public static function analyzeExpression( return false; } + $codebase = $statements_analyzer->getCodebase(); + $bound_this_type = $stmt->getAttribute('psalm-closure-this-type'); $bound_self = null; if ($bound_this_type instanceof Union) { foreach ($bound_this_type->getAtomicTypes() as $bound_atomic) { - if ($bound_atomic instanceof TNamedObject) { + if ($bound_atomic instanceof TNamedObject + && $codebase->classlike_storage_provider->has($bound_atomic->value) + ) { $bound_self = $bound_atomic->value; break; } } - $closure_analyzer->bound_this_class = $bound_self; - $use_context = new Context($bound_self ?? $context->self); - } else { - $use_context = new Context($context->self); + if ($bound_self === null) { + $bound_this_type = null; + } else { + $closure_analyzer->bound_this_class = $bound_self; + } } - $codebase = $statements_analyzer->getCodebase(); + $use_context = new Context($bound_self ?? $context->self); if ($bound_this_type instanceof Union) { if (!$closure_analyzer->isStatic()) { @@ -137,18 +142,22 @@ public static function analyzeExpression( } $properties_class = $bound_this_type instanceof Union - ? ($bound_self ?? null) + ? $bound_self : $context->self; if ($properties_class !== null && $codebase->classlike_storage_provider->has($properties_class)) { $self_class_storage = $codebase->classlike_storage_provider->get($properties_class); + $parent_fqcln = $bound_this_type instanceof Union + ? $self_class_storage->parent_class + : $statements_analyzer->getParentFQCLN(); + ClassAnalyzer::addContextProperties( $statements_analyzer, $self_class_storage, $use_context, $properties_class, - $self_class_storage->parent_class, + $parent_fqcln, ); } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 81ace1a2a69..756cd84edbd 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -584,6 +584,8 @@ private static function applyParamClosureThisHint( $self_fq_class_name = $context->self; $static_fq_class_name = null; + $parent_fq_class_name = null; + $static_class_is_final = false; if ($method_id !== null && str_contains($method_id, '::')) { [$called_class, $method_name] = explode('::', $method_id, 2); @@ -593,15 +595,23 @@ private static function applyParamClosureThisHint( $method_name_lc = strtolower($method_name); if ($codebase->classlike_storage_provider->has($called_class)) { - $class_storage = $codebase->classlike_storage_provider->get($called_class); + $called_class_storage = $codebase->classlike_storage_provider->get($called_class); + $static_class_is_final = $called_class_storage->final; - if (isset($class_storage->declaring_method_ids[$method_name_lc])) { - $self_fq_class_name = $class_storage->declaring_method_ids[$method_name_lc] + if (isset($called_class_storage->declaring_method_ids[$method_name_lc])) { + $self_fq_class_name = $called_class_storage->declaring_method_ids[$method_name_lc] ->fq_class_name; } } } + if ($self_fq_class_name !== null + && $codebase->classlike_storage_provider->has($self_fq_class_name) + ) { + $parent_fq_class_name = $codebase->classlike_storage_provider->get($self_fq_class_name) + ->parent_class; + } + $closure_this_type = $param->closure_this_type; if ($template_result->lower_bounds || $template_result->template_types) { @@ -612,7 +622,7 @@ private static function applyParamClosureThisHint( $statements_analyzer, null, null, - null, + $context->self, $context->calling_method_id ?: $context->calling_function_id, ); @@ -624,7 +634,7 @@ private static function applyParamClosureThisHint( } $static_type = $static_fq_class_name !== null - ? new TNamedObject($static_fq_class_name, true) + ? new TNamedObject($static_fq_class_name, true, $static_class_is_final) : null; $closure_this_type = TypeExpander::expandUnion( @@ -632,10 +642,10 @@ private static function applyParamClosureThisHint( $closure_this_type, $self_fq_class_name, $static_type, - null, + $parent_fq_class_name, true, false, - false, + $static_class_is_final, true, ); diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php index 500e9a92abf..1c0cfe6107c 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php @@ -329,6 +329,7 @@ public static function addDocblockInfo( $storage, $codebase, $file_storage, + $classlike_storage, ); } @@ -1458,6 +1459,7 @@ private static function handleParamClosureThis( FunctionLikeStorage $storage, Codebase $codebase, FileStorage $file_storage, + ?ClassLikeStorage $classlike_storage, ): void { $param_name = substr($docblock_param_closure_this['name'], 1); @@ -1468,6 +1470,8 @@ private static function handleParamClosureThis( $aliases, $function_template_types + $class_template_types, $type_aliases, + $classlike_storage->name ?? null, + $classlike_storage->parent_class ?? null, ), null, $function_template_types + $class_template_types, diff --git a/tests/ParamClosureThisTest.php b/tests/ParamClosureThisTest.php index c2b937f0326..8d6b7708747 100644 --- a/tests/ParamClosureThisTest.php +++ b/tests/ParamClosureThisTest.php @@ -124,6 +124,42 @@ function withBound(Closure $cb): void { }); ', ], + 'parentResolvesToParentClass' => [ + 'code' => 'a_prop; + }); + ', + ], + 'selfWithClassConstantInDocblockResolves' => [ + 'code' => 'value; + }); + ', + ], 'selfResolvesToDeclaringClassOnInheritedStatic' => [ 'code' => ' Date: Mon, 18 May 2026 22:31:39 +0200 Subject: [PATCH 3/4] Fix variadic @param-closure-this; add coverage - Parser now strips the leading ... from a variadic param name (e.g., '@param-closure-this Bound ...$cbs') after stripping the optional &. Without this, the parameter-name lookup in handleParamClosureThis searched for the literal '..cbs' against storage names like 'cbs' and never matched, so the hint was silently dropped for variadic callbacks. - Tests: cover variadic closure binding and class-generic template binding (the latter pins down the case where T is a class-level template, which unlike free-function templates is already resolved at arg-evaluation time). Free-function template binding (e.g. '@template T' on a function with '@param-closure-this T $cb') remains a known limitation in this PR because template inference for free-function params runs in checkArgumentsMatch after analyze() has already evaluated the closure body. --- .../Reflector/FunctionLikeDocblockParser.php | 4 ++ tests/ParamClosureThisTest.php | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index 6e1ab75996c..9512c3eaa63 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -208,6 +208,10 @@ public static function parse( $line_parts[1] = substr($line_parts[1], 1); } + if (str_starts_with($line_parts[1], '...')) { + $line_parts[1] = substr($line_parts[1], 3); + } + $line_parts[0] = CommentAnalyzer::sanitizeDocblockType($line_parts[0]); if ($line_parts[0] === '' diff --git a/tests/ParamClosureThisTest.php b/tests/ParamClosureThisTest.php index 8d6b7708747..116e928da04 100644 --- a/tests/ParamClosureThisTest.php +++ b/tests/ParamClosureThisTest.php @@ -200,6 +200,52 @@ class Child extends Base { }); ', ], + 'variadicClosureParamBindsEachClosure' => [ + 'code' => 'call(new Bound()); + } + } + + eachCallback(function (): int { + return $this->p; + }); + ', + ], + 'classGenericTemplateBindsClosureThis' => [ + 'code' => 'obj = $obj; + } + + /** + * @param-closure-this T $cb + */ + public function with(Closure $cb): void { + $cb->call($this->obj); + } + } + + $tap = new Tap(new Container()); + $tap->with(function (): int { + return $this->value; + }); + ', + ], 'bindInsideMethodOverridesCallerThis' => [ 'code' => ' Date: Mon, 18 May 2026 23:09:06 +0200 Subject: [PATCH 4/4] Clear closure-this attribute per arg; doc cleanup - ArgumentsAnalyzer now nulls the psalm-closure-this-type AST attribute on every Closure/ArrowFunction arg before deciding whether to stamp the hint, so a prior bound-call to the same closure node (or a previous method-resolution candidate) cannot leak its binding into a subsequent unbound code path. Adds a regression test. - Docs: announce the @phpstan-param-closure-this alias and list parent among the supported bound-type forms. --- docs/annotating_code/supported_annotations.md | 4 +-- .../Expression/Call/ArgumentsAnalyzer.php | 26 ++++++++++--------- tests/ParamClosureThisTest.php | 18 +++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/annotating_code/supported_annotations.md b/docs/annotating_code/supported_annotations.md index b1168178caf..4dca6930c0e 100644 --- a/docs/annotating_code/supported_annotations.md +++ b/docs/annotating_code/supported_annotations.md @@ -121,9 +121,9 @@ function addFoo(?string &$s) : void { } ``` -### `@param-closure-this`, `@psalm-param-closure-this` +### `@param-closure-this`, `@psalm-param-closure-this`, `@phpstan-param-closure-this` -This is used to bind `$this` inside a `Closure` or arrow-function argument to a specific class type. Use it when the receiving function or method runs the callback with `Closure::bind` / `Closure::call` so that `$this` resolves to a different object than the caller's `$this`. The bound type may be a class name, `$this`, `static`, `self`, or a template parameter. +This is used to bind `$this` inside a `Closure` or arrow-function argument to a specific class type. Use it when the receiving function or method runs the callback with `Closure::bind` / `Closure::call` so that `$this` resolves to a different object than the caller's `$this`. The bound type may be a class name, `$this`, `static`, `self`, `parent`, or a class-level template parameter. ```php value instanceof PhpParser\Node\Expr\Closure - || $arg->value instanceof PhpParser\Node\Expr\ArrowFunction) - && $param - && $param->closure_this_type + if ($arg->value instanceof PhpParser\Node\Expr\Closure + || $arg->value instanceof PhpParser\Node\Expr\ArrowFunction ) { - self::applyParamClosureThisHint( - $statements_analyzer, - $method_id, - $context, - $template_result ?? new TemplateResult([], []), - $arg, - $param, - ); + $arg->value->setAttribute('psalm-closure-this-type', null); + + if ($param && $param->closure_this_type) { + self::applyParamClosureThisHint( + $statements_analyzer, + $method_id, + $context, + $template_result ?? new TemplateResult([], []), + $arg, + $param, + ); + } } $was_inside_call = $context->inside_call; diff --git a/tests/ParamClosureThisTest.php b/tests/ParamClosureThisTest.php index 116e928da04..ae5fc971096 100644 --- a/tests/ParamClosureThisTest.php +++ b/tests/ParamClosureThisTest.php @@ -277,6 +277,24 @@ private function run(Closure $cb): void { public function providerInvalidCodeParse(): iterable { return [ + 'closureNodeUnboundAfterPreviousBoundCallDoesNotLeak' => [ + 'code' => 'call(new Bound()); + } + + function unbound(Closure $cb): void { + $cb(); + } + + bound(function (): int { return $this->p; }); + unbound(function (): int { return $this->p; }); + ', + 'error_message' => 'InvalidScope', + ], 'callerPropertyNotVisibleInsideBoundClosure' => [ 'code' => '