Skip to content
Merged
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
8 changes: 6 additions & 2 deletions src/Internal/Docblock/DocblockNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public static function normalize(string $doc): string
$doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc;
}

if (stripos($doc, 'static') !== false && stripos($doc, 'closure') !== false) {
$doc = preg_replace('/(?:\(\s*static\s+Closure\s*\)|static\s+Closure\b)/i', 'static-closure', $doc) ?? $doc;
}

if (str_contains($doc, '@self-out') && ! str_contains($doc, '@phpstan-self-out') && ! str_contains($doc, '@psalm-self-out')) {
$doc = preg_replace('/@self-out\b/', '@phpstan-self-out', $doc) ?? $doc;
}
Expand All @@ -46,8 +50,8 @@ public static function normalize(string $doc): string
$doc = preg_replace('/@this-out\b/', '@phpstan-this-out', $doc) ?? $doc;
}

if (str_contains($doc, 'callable') || str_contains($doc, 'Closure')) {
$doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc;
if (stripos($doc, 'callable') !== false || stripos($doc, 'closure') !== false) {
$doc = preg_replace('/(callable|Closure|static-closure)\s*\(([^)]*)\)(?!\s*:)/i', '$1($2): mixed', $doc) ?? $doc;
}

if (str_contains($doc, '::') && str_contains($doc, ':')) {
Expand Down
5 changes: 5 additions & 0 deletions src/Internal/Generics/TemplateManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
use PHPStan\PhpDocParser\Parser\TokenIterator;
Expand Down Expand Up @@ -1522,6 +1523,10 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref):
return new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->name, $ref));
}

if ($n instanceof ThisTypeNode) {
return new IdentifierTypeNode($ref->getName());
}

if ($n instanceof GenericTypeNode) {
$lower = strtolower($n->type->name);
$baseName = match ($lower) {
Expand Down
28 changes: 23 additions & 5 deletions src/Internal/Resolver/SpecialTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct
$declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : ($ref instanceof \ReflectionClass ? $ref->getName() : null);

if ($node instanceof ThisTypeNode) {
if ($thisObj !== null) {
return new IdentifierTypeNode($thisObj::class);
}

if (\is_string($context)) {
$callingClass = strstr($context, '::', true);
if ($callingClass !== false && self::symbolExists($callingClass)) {
return new IdentifierTypeNode($callingClass);
}
}

return $node;
}

Expand Down Expand Up @@ -442,20 +453,27 @@ private static function resolveIdentifier(

if ($lower === '$this') {
if ($thisObj !== null) {
return new IdentifierTypeNode(\get_class($thisObj));
return new IdentifierTypeNode($thisObj::class);
}

if (\is_string($context)) {
$callingClass = strstr($context, '::', true);
if ($callingClass !== false && self::symbolExists($callingClass)) {
return new IdentifierTypeNode($callingClass);
}
}

return $node;
}

if ($lower === 'static') {
if ($thisObj !== null) {
return new IdentifierTypeNode(\get_class($thisObj));
return new IdentifierTypeNode($thisObj::class);
}

if (\is_string($context) && str_contains($context, '::')) {
$callingClass = explode('::', $context, 2)[0];
if (class_exists($callingClass) || interface_exists($callingClass) || trait_exists($callingClass) || enum_exists($callingClass)) {
if (\is_string($context)) {
$callingClass = strstr($context, '::', true);
if ($callingClass !== false && self::symbolExists($callingClass)) {
return new IdentifierTypeNode($callingClass);
}
}
Expand Down
47 changes: 47 additions & 0 deletions src/Internal/Validator/CallableValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace TypePHP\Internal\Validator;

use Closure;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use ReflectionFunction;
use TypePHP\Internal\Diagnostic\ErrorFactory;
use TypePHP\Internal\Diagnostic\ErrorMessage;
use TypePHP\Internal\Diagnostic\TypeFormatter;
use TypePHP\Internal\Wrapper\CallableWrapper;

/**
* @internal Validates callable and Closure contracts at boundaries.
*/
final class CallableValidator implements TypeValidatorInterface
{
public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage
{
/** @var CallableTypeNode $node */
$identifierName = strtolower(ltrim($node->identifier->name, '\\'));

if (str_contains($identifierName, 'closure')) {
if (! ($value instanceof Closure)) {
return ErrorFactory::createError($context . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given');
}

if (str_contains($identifierName, 'static')) {
$refFunc = new ReflectionFunction($value);
if ($refFunc->getClosureThis() !== null) {
return ErrorFactory::createError($context . ' must be a static Closure (not bound to $this)');
}
}

return null;
}

if (! CallableWrapper::isCallable($value)) {
return ErrorFactory::createError($context . ' must be of type callable, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given');
}

return null;
}
}
5 changes: 5 additions & 0 deletions src/Internal/Validator/TypeValidatorRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
Expand Down Expand Up @@ -39,6 +40,8 @@ final class TypeValidatorRegistry

private ConstValidator $constValidator;

private CallableValidator $callableValidator;

/**
* Static map for fast validator resolution.
*
Expand All @@ -61,6 +64,7 @@ public function __construct()
$this->arrayShapeValidator = new ArrayShapeValidator();
$this->objectShapeValidator = new ObjectShapeValidator();
$this->constValidator = new ConstValidator();
$this->callableValidator = new CallableValidator();

$this->validatorMap = [
IdentifierTypeNode::class => $this->identifierValidator,
Expand All @@ -72,6 +76,7 @@ public function __construct()
ObjectShapeNode::class => $this->objectShapeValidator,
IntersectionTypeNode::class => $this->intersectionValidator,
ConstTypeNode::class => $this->constValidator,
CallableTypeNode::class => $this->callableValidator,
];
}

Expand Down
55 changes: 55 additions & 0 deletions tests/Internal/Docblock/DocblockNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,61 @@
});
});

describe('Static Closure Normalization', function () {
test('normalizes parenthesized (static Closure)(args) to static-closure(args)', function () {
$doc = '/** @param (static Closure)(int): int $fn */';
$expected = '/** @param static-closure(int): int $fn */';
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
});

test('normalizes parenthesized (static Closure) with arbitrary whitespace', function () {
$doc = '/** @param ( static Closure )(int, string): bool $fn */';
$expected = '/** @param static-closure(int, string): bool $fn */';
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
});

test('normalizes bare static Closure(args) without parentheses', function () {
$doc = '/** @param static Closure(int): int $fn */';
$expected = '/** @param static-closure(int): int $fn */';
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
});

test('normalizes bare static Closure parameter without argument list', function () {
$docParen = '/** @param (static Closure) $fn */';
$expectedParen = '/** @param static-closure $fn */';
expect(DocblockNormalizer::normalize($docParen))->toBe($expectedParen);

$docBare = '/** @param static Closure $fn */';
$expectedBare = '/** @param static-closure $fn */';
expect(DocblockNormalizer::normalize($docBare))->toBe($expectedBare);
});

test('normalizes case-insensitive static Closure variants', function () {
$doc = '/** @param (STATIC closure)(int): int $fn */';
$expected = '/** @param static-closure(int): int $fn */';
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
});

test('normalizes static Closure with omitted return type to mixed', function () {
$doc = '/** @param (static Closure)(int $x) $fn */';
$expected = '/** @param static-closure(int $x): mixed $fn */';
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
});

test('preserves already canonical static-closure syntax untouched', function () {
$doc = '/** @param static-closure(int): string $fn */';
expect(DocblockNormalizer::normalize($doc))->toBe($doc);
});

test('does not affect standard non-static Closure or standalone static keywords', function () {
$closureDoc = '/** @param Closure(int): string $fn */';
expect(DocblockNormalizer::normalize($closureDoc))->toBe($closureDoc);

$staticDoc = '/** @return static */';
expect(DocblockNormalizer::normalize($staticDoc))->toBe($staticDoc);
});
});

describe('Callable and Closure Return Type Normalization', function () {
test('auto-completes omitted return types for callable and Closure signatures', function () {
$doc1 = '/** @var callable(int[] $items) $callback */';
Expand Down
Loading
Loading