From 103bc9b429c630ad8f0bdf3d2dfc72cd9a53cc1c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 30 Sep 2026 01:15:41 +0800 Subject: [PATCH 1/3] Add support for $this, static, and self in generic type arguments resolution --- src/Internal/Generics/TemplateManager.php | 5 + src/Internal/Resolver/SpecialTypeResolver.php | 28 ++- .../Generics/GenericThisTypeArgumentTest.php | 171 ++++++++++++++++++ 3 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 tests/TypeChecking/Generics/GenericThisTypeArgumentTest.php diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index 7a490f0..3db5178 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -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; @@ -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) { diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 75929ac..e712563 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -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; } @@ -442,7 +453,14 @@ 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; @@ -450,12 +468,12 @@ private static function resolveIdentifier( 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); } } diff --git a/tests/TypeChecking/Generics/GenericThisTypeArgumentTest.php b/tests/TypeChecking/Generics/GenericThisTypeArgumentTest.php new file mode 100644 index 0000000..8292d72 --- /dev/null +++ b/tests/TypeChecking/Generics/GenericThisTypeArgumentTest.php @@ -0,0 +1,171 @@ + + */ + public function schoolYearThis(): BelongsToRelationFixture + { + return new BelongsToRelationFixture($this, new SchoolYearModelFixture()); + } + + /** + * @return BelongsToRelationFixture + */ + public function schoolYearStatic(): BelongsToRelationFixture + { + return new BelongsToRelationFixture($this, new SchoolYearModelFixture()); + } + + /** + * @return BelongsToRelationFixture + */ + public function schoolYearSelf(): BelongsToRelationFixture + { + return new BelongsToRelationFixture($this, new SchoolYearModelFixture()); + } +} + +class GraduatingStudentFixture extends StudentModelFixture +{ + /** + * When the subclass overrides with self, self refers to GraduatingStudentFixture: + * + * @return BelongsToRelationFixture + */ + public function schoolYearSelfOverride(): BelongsToRelationFixture + { + return new BelongsToRelationFixture($this, new SchoolYearModelFixture()); + } +} + +class NonModelFixture +{ + /** + * @return BelongsToRelationFixture + */ + public function invalidRelationThis(): BelongsToRelationFixture + { + return new BelongsToRelationFixture(new SchoolYearModelFixture(), new SchoolYearModelFixture()); + } + + /** + * @return BelongsToRelationFixture + */ + public function invalidRelationStatic(): BelongsToRelationFixture + { + return new BelongsToRelationFixture(new SchoolYearModelFixture(), new SchoolYearModelFixture()); + } + + /** + * @return BelongsToRelationFixture + */ + public function invalidRelationSelf(): BelongsToRelationFixture + { + return new BelongsToRelationFixture(new SchoolYearModelFixture(), new SchoolYearModelFixture()); + } +} + +describe('Generic Type Argument ($this, static, self) Resolution', function () { + describe('Valid Eloquent Models', function () { + test('resolves $this inside generic argument to declaring model class', function () { + $student = new StudentModelFixture(); + $relation = $student->schoolYearThis(); + + expect($relation)->toBeInstanceOf(BelongsToRelationFixture::class) + ->and($relation->parent)->toBe($student) + ->and($relation->related)->toBeInstanceOf(SchoolYearModelFixture::class) + ; + }); + + test('resolves static inside generic argument to calling model class', function () { + $student = new StudentModelFixture(); + $relation = $student->schoolYearStatic(); + + expect($relation)->toBeInstanceOf(BelongsToRelationFixture::class); + }); + + test('resolves self inside generic argument to declaring model class', function () { + $student = new StudentModelFixture(); + $relation = $student->schoolYearSelf(); + + expect($relation)->toBeInstanceOf(BelongsToRelationFixture::class); + }); + + test('resolves $this and static dynamically on child model subclasses', function () { + $grad = new GraduatingStudentFixture(); + + expect($grad->schoolYearThis())->toBeInstanceOf(BelongsToRelationFixture::class) + ->and($grad->schoolYearStatic())->toBeInstanceOf(BelongsToRelationFixture::class) + ->and($grad->schoolYearSelfOverride())->toBeInstanceOf(BelongsToRelationFixture::class) + ; + }); + }); + + describe('Invalid Non-Model Classes (Upper Bound Rejections)', function () { + test('rejects $this when declaring class does not satisfy upper bound Model', function () { + $nonModel = new NonModelFixture(); + + expect(fn () => $nonModel->invalidRelationThis()) + ->toThrow( + TypeError::class, + 'Generic type argument ' . NonModelFixture::class . ' does not satisfy upper bound' + ) + ; + }); + + test('rejects static when declaring class does not satisfy upper bound Model', function () { + $nonModel = new NonModelFixture(); + + expect(fn () => $nonModel->invalidRelationStatic()) + ->toThrow( + TypeError::class, + 'Generic type argument ' . NonModelFixture::class . ' does not satisfy upper bound' + ) + ; + }); + + test('rejects self when declaring class does not satisfy upper bound Model', function () { + $nonModel = new NonModelFixture(); + + expect(fn () => $nonModel->invalidRelationSelf()) + ->toThrow( + TypeError::class, + 'Generic type argument ' . NonModelFixture::class . ' does not satisfy upper bound' + ) + ; + }); + }); +}); From 2ae95947c7616c21d41ccf9da1011a1df30f0a4b Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 30 Sep 2026 01:45:25 +0800 Subject: [PATCH 2/3] Implement static Closure normalization and add CallableValidator with comprehensive edge cases tests --- src/Internal/Docblock/DocblockNormalizer.php | 8 +- src/Internal/Validator/CallableValidator.php | 47 +++++ .../Validator/TypeValidatorRegistry.php | 5 + .../Docblock/DocblockNormalizerTest.php | 55 ++++++ .../Validator/CallableValidatorTest.php | 186 ++++++++++++++++++ .../CallableEdgeCasesTest.php | 90 +++++++++ 6 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 src/Internal/Validator/CallableValidator.php create mode 100644 tests/Internal/Validator/CallableValidatorTest.php create mode 100644 tests/TypeChecking/CallablesAndIterators/CallableEdgeCasesTest.php diff --git a/src/Internal/Docblock/DocblockNormalizer.php b/src/Internal/Docblock/DocblockNormalizer.php index b13b8e8..5a2b401 100644 --- a/src/Internal/Docblock/DocblockNormalizer.php +++ b/src/Internal/Docblock/DocblockNormalizer.php @@ -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; } @@ -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, ':')) { diff --git a/src/Internal/Validator/CallableValidator.php b/src/Internal/Validator/CallableValidator.php new file mode 100644 index 0000000..c277bc2 --- /dev/null +++ b/src/Internal/Validator/CallableValidator.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/src/Internal/Validator/TypeValidatorRegistry.php b/src/Internal/Validator/TypeValidatorRegistry.php index 8fa29d4..46662a4 100644 --- a/src/Internal/Validator/TypeValidatorRegistry.php +++ b/src/Internal/Validator/TypeValidatorRegistry.php @@ -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; @@ -39,6 +40,8 @@ final class TypeValidatorRegistry private ConstValidator $constValidator; + private CallableValidator $callableValidator; + /** * Static map for fast validator resolution. * @@ -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, @@ -72,6 +76,7 @@ public function __construct() ObjectShapeNode::class => $this->objectShapeValidator, IntersectionTypeNode::class => $this->intersectionValidator, ConstTypeNode::class => $this->constValidator, + CallableTypeNode::class => $this->callableValidator, ]; } diff --git a/tests/Internal/Docblock/DocblockNormalizerTest.php b/tests/Internal/Docblock/DocblockNormalizerTest.php index 45bafa6..c5a5f3f 100644 --- a/tests/Internal/Docblock/DocblockNormalizerTest.php +++ b/tests/Internal/Docblock/DocblockNormalizerTest.php @@ -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 */'; diff --git a/tests/Internal/Validator/CallableValidatorTest.php b/tests/Internal/Validator/CallableValidatorTest.php new file mode 100644 index 0000000..0dde59b --- /dev/null +++ b/tests/Internal/Validator/CallableValidatorTest.php @@ -0,0 +1,186 @@ + $this; + } + + public function getStaticClosure(): Closure + { + return static fn () => 10; + } + + public function validInstanceMethod(): string + { + return 'ok'; + } + + public static function validStaticMethod(): string + { + return 'static_ok'; + } +} + +describe('CallableValidator Unit Tests', function () { + beforeEach(function () { + $this->registry = new TypeValidatorRegistry(); + $this->validator = new CallableValidator(); + + $this->callableNode = new CallableTypeNode(new IdentifierTypeNode('callable'), [], new IdentifierTypeNode('void'), []); + $this->closureNode = new CallableTypeNode(new IdentifierTypeNode('Closure'), [], new IdentifierTypeNode('void'), []); + $this->staticClosureNode = new CallableTypeNode(new IdentifierTypeNode('static-closure'), [], new IdentifierTypeNode('void'), []); + $this->pureCallableNode = new CallableTypeNode(new IdentifierTypeNode('pure-callable'), [], new IdentifierTypeNode('void'), []); + $this->pureClosureNode = new CallableTypeNode(new IdentifierTypeNode('pure-Closure'), [], new IdentifierTypeNode('void'), []); + $this->staticPureClosureNode = new CallableTypeNode(new IdentifierTypeNode('static-pure-closure'), [], new IdentifierTypeNode('void'), []); + }); + + describe('callable & pure-callable Validation', function () { + test('accepts valid closures, strings, invokables, and array callables', function () { + $host = new CallableTestHost(); + $invokable = new class () { + public function __invoke(): void + { + } + }; + + expect($this->validator->validate(fn () => null, $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate('strlen', $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate($invokable, $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate([$host, 'validInstanceMethod'], $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate([CallableTestHost::class, 'validStaticMethod'], $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate('strlen', $this->pureCallableNode, 'cb', $this->registry))->toBeNull() + ; + }); + + test('rejects non-callable arrays (e.g. undefined methods)', function () { + $badArray = [new stdClass(), 'nonExistentMethod']; + + $err = $this->validator->validate($badArray, $this->callableNode, 'callback', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('callback must be of type callable, list (2 items) given') + ; + }); + + test('rejects non-callable primitives and objects', function () { + expect($this->validator->validate(12345, $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate('non_existent_function_xyz', $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(new stdClass(), $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate([], $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(null, $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; + }); + }); + + describe('Closure & pure-Closure Validation', function () { + test('accepts native Closure instances', function () { + $fn = fn () => 'hello'; + + expect($this->validator->validate($fn, $this->closureNode, 'fn', $this->registry))->toBeNull() + ->and($this->validator->validate($fn, $this->pureClosureNode, 'fn', $this->registry))->toBeNull() + ; + }); + + test('rejects non-closure callables even if valid callable (string, array, invokable)', function () { + $host = new CallableTestHost(); + $invokable = new class () { + public function __invoke(): void + { + } + }; + + $errString = $this->validator->validate('strlen', $this->closureNode, 'fn', $this->registry); + expect($errString)->toBeInstanceOf(ErrorMessage::class) + ->and($errString->getMessage())->toBe("fn must be of type Closure, string 'strlen' given") + ; + + $errArray = $this->validator->validate([$host, 'validInstanceMethod'], $this->closureNode, 'fn', $this->registry); + expect($errArray)->toBeInstanceOf(ErrorMessage::class) + ->and($errArray->getMessage())->toBe('fn must be of type Closure, list (2 items) given') + ; + + $errInvokable = $this->validator->validate($invokable, $this->closureNode, 'fn', $this->registry); + expect($errInvokable)->toBeInstanceOf(ErrorMessage::class) + ->and($errInvokable->getMessage())->toContain('must be of type Closure') + ; + }); + + test('rejects non-callable arrays on Closure constraints', function () { + $badArray = [new stdClass(), 'nonExistentMethod']; + + $err = $this->validator->validate($badArray, $this->closureNode, 'fn', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('fn must be of type Closure, list (2 items) given') + ; + }); + }); + + describe('static-closure & static-pure-closure Validation', function () { + test('accepts genuinely static closures', function () { + $staticFn = static fn () => 42; + $host = new CallableTestHost(); + + expect($this->validator->validate($staticFn, $this->staticClosureNode, 'fn', $this->registry))->toBeNull() + ->and($this->validator->validate($host->getStaticClosure(), $this->staticClosureNode, 'fn', $this->registry))->toBeNull() + ->and($this->validator->validate($staticFn, $this->staticPureClosureNode, 'fn', $this->registry))->toBeNull() + ; + }); + + test('rejects closures bound to $this instance', function () { + $host = new CallableTestHost(); + $boundFn = $host->getBoundClosure(); + + $err = $this->validator->validate($boundFn, $this->staticClosureNode, 'fn', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('fn must be a static Closure (not bound to $this)') + ; + + $errPure = $this->validator->validate($boundFn, $this->staticPureClosureNode, 'fn', $this->registry); + expect($errPure)->toBeInstanceOf(ErrorMessage::class) + ->and($errPure->getMessage())->toBe('fn must be a static Closure (not bound to $this)') + ; + }); + + test('rejects non-closure values on static-closure constraints', function () { + $err = $this->validator->validate('strlen', $this->staticClosureNode, 'fn', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be of type Closure') + ; + }); + }); + + describe('Sensitivity Redaction (#[SensitiveParameter])', function () { + test('formats error without leaking value when isSensitive is true', function () { + $err = $this->validator->validate('secret_non_callable_payload', $this->callableNode, 'param', $this->registry, isSensitive: true); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('param must be of type callable, string given') + ->and($err->getMessage())->not()->toContain('secret_non_callable_payload') + ; + }); + }); + + describe('Registry Routing Integration', function () { + test('routes CallableTypeNode directly through TypeValidatorRegistry', function () { + expect($this->registry->validate(fn () => null, $this->callableNode, 'test'))->toBeNull(); + + $err = $this->registry->validate([new stdClass(), 'missing'], $this->callableNode, 'test'); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('test must be of type callable, list (2 items) given') + ; + }); + }); +}); diff --git a/tests/TypeChecking/CallablesAndIterators/CallableEdgeCasesTest.php b/tests/TypeChecking/CallablesAndIterators/CallableEdgeCasesTest.php new file mode 100644 index 0000000..ffa522e --- /dev/null +++ b/tests/TypeChecking/CallablesAndIterators/CallableEdgeCasesTest.php @@ -0,0 +1,90 @@ + $x + 1; + } +} + +describe('Callable & Closure Edge Cases', function () { + describe('Bug 1: Non-Callable Values and Arrays against Closure/Callable constraints', function () { + test('rejects non-callable array on parameter expecting Closure', function () { + expect(fn () => testRequiresClosureParam([new stdClass(), 'nonexistentMethod'])) + ->toThrow(TypeError::class, 'must be of type Closure, list (2 items) given') + ; + }); + + test('rejects non-callable array on parameter expecting callable', function () { + expect(fn () => testRequiresCallableParam([new stdClass(), 'nonexistentMethod'])) + ->toThrow(TypeError::class, 'must be of type callable, list (2 items) given') + ; + }); + + test('rejects non-callable scalar on parameter expecting callable', function () { + expect(fn () => testRequiresCallableParam(12345)) + ->toThrow(TypeError::class, 'must be of type callable, int (12345) given') + ; + }); + }); + + describe('Bug 2: (static Closure) syntax variants', function () { + test('rejects bound closure when declared as (static Closure)', function () { + $host = new StaticClosureHost(); + + expect(fn () => testParenStaticClosureParam($host->getBound())) + ->toThrow(TypeError::class, 'must be a static Closure (not bound to $this)') + ; + }); + + test('rejects bound closure when declared as static Closure without parentheses', function () { + $host = new StaticClosureHost(); + + expect(fn () => testBareStaticClosureParam($host->getBound())) + ->toThrow(TypeError::class, 'must be a static Closure (not bound to $this)') + ; + }); + + test('accepts genuinely static closure on (static Closure)', function () { + expect(testParenStaticClosureParam(static fn (int $x): int => $x * 2))->toBeNull(); + expect(testBareStaticClosureParam(static fn (int $x): int => $x * 2))->toBeNull(); + }); + }); +}); From 619c42d0df1b30978b823deec5cf1bace6da7a4e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 30 Sep 2026 02:14:22 +0800 Subject: [PATCH 3/3] Refactor CallableValidator unit tests for improved clarity and organization --- .../Validator/CallableValidatorTest.php | 232 +++++++++--------- 1 file changed, 111 insertions(+), 121 deletions(-) diff --git a/tests/Internal/Validator/CallableValidatorTest.php b/tests/Internal/Validator/CallableValidatorTest.php index 0dde59b..9f62cff 100644 --- a/tests/Internal/Validator/CallableValidatorTest.php +++ b/tests/Internal/Validator/CallableValidatorTest.php @@ -44,143 +44,133 @@ public static function validStaticMethod(): string $this->staticPureClosureNode = new CallableTypeNode(new IdentifierTypeNode('static-pure-closure'), [], new IdentifierTypeNode('void'), []); }); - describe('callable & pure-callable Validation', function () { - test('accepts valid closures, strings, invokables, and array callables', function () { - $host = new CallableTestHost(); - $invokable = new class () { - public function __invoke(): void - { - } - }; - - expect($this->validator->validate(fn () => null, $this->callableNode, 'cb', $this->registry))->toBeNull() - ->and($this->validator->validate('strlen', $this->callableNode, 'cb', $this->registry))->toBeNull() - ->and($this->validator->validate($invokable, $this->callableNode, 'cb', $this->registry))->toBeNull() - ->and($this->validator->validate([$host, 'validInstanceMethod'], $this->callableNode, 'cb', $this->registry))->toBeNull() - ->and($this->validator->validate([CallableTestHost::class, 'validStaticMethod'], $this->callableNode, 'cb', $this->registry))->toBeNull() - ->and($this->validator->validate('strlen', $this->pureCallableNode, 'cb', $this->registry))->toBeNull() - ; - }); - - test('rejects non-callable arrays (e.g. undefined methods)', function () { - $badArray = [new stdClass(), 'nonExistentMethod']; - - $err = $this->validator->validate($badArray, $this->callableNode, 'callback', $this->registry); - - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toBe('callback must be of type callable, list (2 items) given') - ; - }); - - test('rejects non-callable primitives and objects', function () { - expect($this->validator->validate(12345, $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ->and($this->validator->validate('non_existent_function_xyz', $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ->and($this->validator->validate(new stdClass(), $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ->and($this->validator->validate([], $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ->and($this->validator->validate(null, $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) - ; - }); + test('accepts valid closures, strings, invokables, and array callables', function () { + $host = new CallableTestHost(); + $invokable = new class () { + public function __invoke(): void + { + } + }; + + expect($this->validator->validate(fn () => null, $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate('strlen', $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate($invokable, $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate([$host, 'validInstanceMethod'], $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate([CallableTestHost::class, 'validStaticMethod'], $this->callableNode, 'cb', $this->registry))->toBeNull() + ->and($this->validator->validate('strlen', $this->pureCallableNode, 'cb', $this->registry))->toBeNull() + ; }); - describe('Closure & pure-Closure Validation', function () { - test('accepts native Closure instances', function () { - $fn = fn () => 'hello'; - - expect($this->validator->validate($fn, $this->closureNode, 'fn', $this->registry))->toBeNull() - ->and($this->validator->validate($fn, $this->pureClosureNode, 'fn', $this->registry))->toBeNull() - ; - }); - - test('rejects non-closure callables even if valid callable (string, array, invokable)', function () { - $host = new CallableTestHost(); - $invokable = new class () { - public function __invoke(): void - { - } - }; - - $errString = $this->validator->validate('strlen', $this->closureNode, 'fn', $this->registry); - expect($errString)->toBeInstanceOf(ErrorMessage::class) - ->and($errString->getMessage())->toBe("fn must be of type Closure, string 'strlen' given") - ; - - $errArray = $this->validator->validate([$host, 'validInstanceMethod'], $this->closureNode, 'fn', $this->registry); - expect($errArray)->toBeInstanceOf(ErrorMessage::class) - ->and($errArray->getMessage())->toBe('fn must be of type Closure, list (2 items) given') - ; - - $errInvokable = $this->validator->validate($invokable, $this->closureNode, 'fn', $this->registry); - expect($errInvokable)->toBeInstanceOf(ErrorMessage::class) - ->and($errInvokable->getMessage())->toContain('must be of type Closure') - ; - }); - - test('rejects non-callable arrays on Closure constraints', function () { - $badArray = [new stdClass(), 'nonExistentMethod']; - - $err = $this->validator->validate($badArray, $this->closureNode, 'fn', $this->registry); - - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toBe('fn must be of type Closure, list (2 items) given') - ; - }); + test('rejects non-callable arrays (e.g. undefined methods)', function () { + $badArray = [new stdClass(), 'nonExistentMethod']; + + $err = $this->validator->validate($badArray, $this->callableNode, 'callback', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('callback must be of type callable, list (2 items) given') + ; + }); + + test('rejects non-callable primitives and objects', function () { + expect($this->validator->validate(12345, $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate('non_existent_function_xyz', $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(new stdClass(), $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate([], $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ->and($this->validator->validate(null, $this->callableNode, 'cb', $this->registry))->toBeInstanceOf(ErrorMessage::class) + ; }); - describe('static-closure & static-pure-closure Validation', function () { - test('accepts genuinely static closures', function () { - $staticFn = static fn () => 42; - $host = new CallableTestHost(); + test('accepts native Closure instances', function () { + $fn = fn () => 'hello'; - expect($this->validator->validate($staticFn, $this->staticClosureNode, 'fn', $this->registry))->toBeNull() - ->and($this->validator->validate($host->getStaticClosure(), $this->staticClosureNode, 'fn', $this->registry))->toBeNull() - ->and($this->validator->validate($staticFn, $this->staticPureClosureNode, 'fn', $this->registry))->toBeNull() - ; - }); + expect($this->validator->validate($fn, $this->closureNode, 'fn', $this->registry))->toBeNull() + ->and($this->validator->validate($fn, $this->pureClosureNode, 'fn', $this->registry))->toBeNull() + ; + }); + + test('rejects non-closure callables even if valid callable (string, array, invokable)', function () { + $host = new CallableTestHost(); + $invokable = new class () { + public function __invoke(): void + { + } + }; + + $errString = $this->validator->validate('strlen', $this->closureNode, 'fn', $this->registry); + expect($errString)->toBeInstanceOf(ErrorMessage::class) + ->and($errString->getMessage())->toBe("fn must be of type Closure, string 'strlen' given") + ; + + $errArray = $this->validator->validate([$host, 'validInstanceMethod'], $this->closureNode, 'fn', $this->registry); + expect($errArray)->toBeInstanceOf(ErrorMessage::class) + ->and($errArray->getMessage())->toBe('fn must be of type Closure, list (2 items) given') + ; + + $errInvokable = $this->validator->validate($invokable, $this->closureNode, 'fn', $this->registry); + expect($errInvokable)->toBeInstanceOf(ErrorMessage::class) + ->and($errInvokable->getMessage())->toContain('must be of type Closure') + ; + }); - test('rejects closures bound to $this instance', function () { - $host = new CallableTestHost(); - $boundFn = $host->getBoundClosure(); + test('rejects non-callable arrays on Closure constraints', function () { + $badArray = [new stdClass(), 'nonExistentMethod']; - $err = $this->validator->validate($boundFn, $this->staticClosureNode, 'fn', $this->registry); + $err = $this->validator->validate($badArray, $this->closureNode, 'fn', $this->registry); - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toBe('fn must be a static Closure (not bound to $this)') - ; + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('fn must be of type Closure, list (2 items) given') + ; + }); - $errPure = $this->validator->validate($boundFn, $this->staticPureClosureNode, 'fn', $this->registry); - expect($errPure)->toBeInstanceOf(ErrorMessage::class) - ->and($errPure->getMessage())->toBe('fn must be a static Closure (not bound to $this)') - ; - }); + test('accepts genuinely static closures', function () { + $staticFn = static fn () => 42; + $host = new CallableTestHost(); + + expect($this->validator->validate($staticFn, $this->staticClosureNode, 'fn', $this->registry))->toBeNull() + ->and($this->validator->validate($host->getStaticClosure(), $this->staticClosureNode, 'fn', $this->registry))->toBeNull() + ->and($this->validator->validate($staticFn, $this->staticPureClosureNode, 'fn', $this->registry))->toBeNull() + ; + }); + + test('rejects closures bound to $this instance', function () { + $host = new CallableTestHost(); + $boundFn = $host->getBoundClosure(); + + $err = $this->validator->validate($boundFn, $this->staticClosureNode, 'fn', $this->registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('fn must be a static Closure (not bound to $this)') + ; + + $errPure = $this->validator->validate($boundFn, $this->staticPureClosureNode, 'fn', $this->registry); + expect($errPure)->toBeInstanceOf(ErrorMessage::class) + ->and($errPure->getMessage())->toBe('fn must be a static Closure (not bound to $this)') + ; + }); - test('rejects non-closure values on static-closure constraints', function () { - $err = $this->validator->validate('strlen', $this->staticClosureNode, 'fn', $this->registry); + test('rejects non-closure values on static-closure constraints', function () { + $err = $this->validator->validate('strlen', $this->staticClosureNode, 'fn', $this->registry); - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain('must be of type Closure') - ; - }); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be of type Closure') + ; }); - describe('Sensitivity Redaction (#[SensitiveParameter])', function () { - test('formats error without leaking value when isSensitive is true', function () { - $err = $this->validator->validate('secret_non_callable_payload', $this->callableNode, 'param', $this->registry, isSensitive: true); + test('formats error without leaking value when isSensitive is true', function () { + $err = $this->validator->validate('secret_non_callable_payload', $this->callableNode, 'param', $this->registry, isSensitive: true); - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toBe('param must be of type callable, string given') - ->and($err->getMessage())->not()->toContain('secret_non_callable_payload') - ; - }); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('param must be of type callable, string given') + ->and($err->getMessage())->not()->toContain('secret_non_callable_payload') + ; }); - describe('Registry Routing Integration', function () { - test('routes CallableTypeNode directly through TypeValidatorRegistry', function () { - expect($this->registry->validate(fn () => null, $this->callableNode, 'test'))->toBeNull(); + test('routes CallableTypeNode directly through TypeValidatorRegistry', function () { + expect($this->registry->validate(fn () => null, $this->callableNode, 'test'))->toBeNull(); - $err = $this->registry->validate([new stdClass(), 'missing'], $this->callableNode, 'test'); - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toBe('test must be of type callable, list (2 items) given') - ; - }); + $err = $this->registry->validate([new stdClass(), 'missing'], $this->callableNode, 'test'); + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toBe('test must be of type callable, list (2 items) given') + ; }); });