Skip to content
Draft
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
25 changes: 25 additions & 0 deletions docs/annotating_code/supported_annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,31 @@ function addFoo(?string &$s) : void {
}
```

### `@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`, `parent`, or a class-level template parameter.

```php
<?php
class Macroable {
/**
* @param-closure-this static $macro
*/
public static function macro(string $name, Closure $macro): void {
// store $macro and later run $macro->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.
Expand Down
2 changes: 1 addition & 1 deletion src/Psalm/DocComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down
65 changes: 52 additions & 13 deletions src/Psalm/Internal/Analyzer/ClosureAnalyzer.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

declare(strict_types=1);
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -85,11 +88,35 @@
return false;
}

$use_context = new Context($context->self);

$codebase = $statements_analyzer->getCodebase();

if (!$statements_analyzer->isStatic() && !$closure_analyzer->isStatic()) {
$bound_this_type = $stmt->getAttribute('psalm-closure-this-type');

Check failure on line 93 in src/Psalm/Internal/Analyzer/ClosureAnalyzer.php

View workflow job for this annotation

GitHub Actions / build

MixedAssignment

src/Psalm/Internal/Analyzer/ClosureAnalyzer.php:93:9: MixedAssignment: Unable to determine the type that $bound_this_type is being assigned to (see https://psalm.dev/032)
$bound_self = null;

if ($bound_this_type instanceof Union) {
foreach ($bound_this_type->getAtomicTypes() as $bound_atomic) {
if ($bound_atomic instanceof TNamedObject
&& $codebase->classlike_storage_provider->has($bound_atomic->value)
) {
$bound_self = $bound_atomic->value;
break;
}
}

if ($bound_self === null) {
$bound_this_type = null;
} else {
$closure_analyzer->bound_this_class = $bound_self;
}
}

$use_context = new Context($bound_self ?? $context->self);

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(
Expand All @@ -106,27 +133,39 @@
}
}

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
: $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,
$context->self,
$statements_analyzer->getParentFQCLN(),
$properties_class,
$parent_fqcln,
);
}

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;
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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;
Expand Down Expand Up @@ -237,6 +238,23 @@
);
}

if ($arg->value instanceof PhpParser\Node\Expr\Closure
|| $arg->value instanceof PhpParser\Node\Expr\ArrowFunction
) {
$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;
$context->inside_call = true;

Expand Down Expand Up @@ -561,6 +579,95 @@
}
}

/**
* 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;
$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);

Check failure on line 607 in src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php

View workflow job for this annotation

GitHub Actions / build

PossiblyUndefinedArrayOffset

src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php:607:29: PossiblyUndefinedArrayOffset: Possibly undefined array key (see https://psalm.dev/167)
$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)) {
$called_class_storage = $codebase->classlike_storage_provider->get($called_class);
$static_class_is_final = $called_class_storage->final;

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) {
$closure_this_type = TemplateStandinTypeReplacer::replace(
$closure_this_type,
$template_result,
$codebase,
$statements_analyzer,
null,
null,
$context->self,
$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, $static_class_is_final)
: null;

$closure_this_type = TypeExpander::expandUnion(
$codebase,
$closure_this_type,
$self_fq_class_name,
$static_type,
$parent_fq_class_name,
true,
false,
$static_class_is_final,
true,
);

$arg->value->setAttribute('psalm-closure-this-type', $closure_this_type);
}

/**
* @param list<PhpParser\Node\Arg> $args
* @param array<int,FunctionLikeParameter> $function_params
Expand Down Expand Up @@ -1754,7 +1861,7 @@
$default_type,
$i,
$context->self,
$context->calling_method_id ?: $context->calling_function_id,

Check failure on line 1864 in src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php

View workflow job for this annotation

GitHub Actions / build

RiskyTruthyFalsyComparison

src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php:1864:25: RiskyTruthyFalsyComparison: Operand of type lowercase-string|null contains type lowercase-string, which can be falsy and truthy. This can cause possibly unexpected behavior. Use strict comparison instead. (see https://psalm.dev/356)
true,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,60 @@ 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);
}

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] === ''
|| ($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) {
Expand Down
Loading
Loading