diff --git a/src/Internal/Ast/ContractVisitor.php b/src/Internal/Ast/ContractVisitor.php index eec3028..0e97c14 100644 --- a/src/Internal/Ast/ContractVisitor.php +++ b/src/Internal/Ast/ContractVisitor.php @@ -15,6 +15,28 @@ final class ContractVisitor extends NodeVisitorAbstract { private ScopeManager $scopeManager; + private string $currentNamespace = ''; + + /** + * @var list + */ + private array $classStack = []; + + /** + * @var list + */ + private array $methodStack = []; + + /** + * @var list + */ + private array $functionStack = []; + + /** + * @var list + */ + private array $thisAvailableStack = []; + public function __construct() { $this->scopeManager = new ScopeManager(); @@ -27,25 +49,15 @@ public function __construct() */ public function enterNode(Node $node): ?array { - if ( - $node instanceof Node\Stmt\Function_ - || $node instanceof Node\Stmt\ClassMethod - || $node instanceof Node\Expr\Closure - || $node instanceof Node\Expr\ArrowFunction - || $node instanceof Node\Stmt\If_ - || $node instanceof Node\Stmt\Else_ - || $node instanceof Node\Stmt\ElseIf_ - || $node instanceof Node\Stmt\Foreach_ - || $node instanceof Node\Stmt\While_ - || $node instanceof Node\Stmt\For_ - || $node instanceof Node\Stmt\Do_ - || $node instanceof Node\Stmt\TryCatch - ) { + $this->trackDeclarationEntry($node); + + if ($this->isScopeBoundary($node)) { $this->scopeManager->pushScope(); } if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { - FunctionContractInjector::inject($node); + $classContext = $this->classStack !== [] ? end($this->classStack) : null; + FunctionContractInjector::inject($node, $classContext); return null; } @@ -57,58 +69,13 @@ public function enterNode(Node $node): ?array } if ($node instanceof Node\Stmt\Return_ && $node->expr !== null) { - $doc = $node->getDocComment(); - if ($doc !== null && str_contains($doc->getText(), '@var')) { - $extracted = DocblockExtractor::extractVarTagFromDoc($doc->getText()); - if ($extracted !== null) { - [$typeString, $varName] = $extracted; - $isApplicableToReturn = ($varName === '') - || ($node->expr instanceof Node\Expr\Variable && $node->expr->name === $varName); - - if ($isApplicableToReturn) { - $effectiveVarName = ($varName !== '') ? $varName : 'return'; - $checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName); - $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine()); - $node->setAttribute('typephp_var_wrapped', true); - } - } - } + $this->handleReturn($node); + + return null; } if ($node instanceof Node\Stmt\Expression) { - $doc = $node->getDocComment(); - if ($doc !== null && str_contains($doc->getText(), '@var')) { - $this->scopeManager->extractVarDocblock($doc->getText(), $node->expr); - } - - if ($node->expr instanceof Node\Expr\Assign) { - $assign = $node->expr; - if ($assign->var instanceof Node\Expr\List_ || ($assign->var instanceof Node\Expr\Array_ && $assign->var->getAttribute('kind') === Node\Expr\Array_::KIND_SHORT)) { - $destructuredVars = $this->extractDestructuringVariables($assign->var); - $checkStmts = []; - - foreach ($destructuredVars as $dVar) { - $varName = $dVar['varName']; - $typeString = $this->scopeManager->getVarTypeFromScope($varName); - - if ($typeString !== null) { - $checkCall = NodeBuilder::createVariableCheckCall($dVar['expr'], $typeString, $varName); - $checkStmt = new Node\Stmt\Expression( - new Node\Expr\Assign( - $dVar['expr'], - NodeBuilder::createTernaryThrowExpr($checkCall, $dVar['expr']->getStartLine()) - ) - ); - $checkStmt->setAttribute('typephp_injected', value: true); - $checkStmts[] = $checkStmt; - } - } - - if ($checkStmts !== []) { - return array_merge([$node], $checkStmts); - } - } - } + return $this->handleExpression($node); } if ($node instanceof Node\Stmt\Foreach_) { @@ -116,34 +83,12 @@ public function enterNode(Node $node): ?array if ($doc !== null && str_contains($doc->getText(), '@var')) { $this->scopeManager->extractVarDocblock($doc->getText(), $node->valueVar); } + + return null; } if ($node instanceof Node\Expr\Assign) { - if ($node->var instanceof Node\Expr\Variable && \is_string($node->var->name)) { - $varName = $node->var->name; - $typeString = $this->scopeManager->getVarTypeFromScope($varName); - - if ($typeString !== null) { - $checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $varName); - $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->var->getStartLine()); - } - } elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { - $propName = $node->var->name->toString(); - $objExpr = $node->var->var; - - $checkCall = NodeBuilder::createPropertyCheckCall($node->expr, $objExpr, $propName); - $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->var->getStartLine()); - } elseif ($node->var instanceof Node\Expr\StaticPropertyFetch && $node->var->name instanceof Node\VarLikeIdentifier) { - $propName = $node->var->name->toString(); - $classExpr = $node->var->class; - - $classArg = $classExpr instanceof Node\Name - ? new Node\Expr\ClassConstFetch($classExpr, 'class') - : $classExpr; - - $checkCall = NodeBuilder::createPropertyCheckCall($node->expr, $classArg, $propName); - $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->var->getStartLine()); - } + $this->handleAssign($node); } return null; @@ -152,33 +97,240 @@ public function enterNode(Node $node): ?array /** * Pops the current lexical scope stack frame or replaces transformed expressions upon leaving a node. */ - public function leaveNode(Node $node): Node|null + public function leaveNode(Node $node): ?Node { + $this->trackDeclarationExit($node); + if ($node instanceof Node\Expr\Clone_) { - if ($node->getAttribute('typephp_wrapped') === true) { - return null; + return $this->wrapClone($node); + } + + if ($this->isScopeBoundary($node)) { + $this->scopeManager->popScope(); + } + + return null; + } + + private function trackDeclarationEntry(Node $node): void + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->currentNamespace = $node->name !== null ? $node->name->toString() : ''; + } elseif ( + $node instanceof Node\Stmt\Class_ + || $node instanceof Node\Stmt\Interface_ + || $node instanceof Node\Stmt\Trait_ + || $node instanceof Node\Stmt\Enum_ + ) { + $this->enterClassLike($node); + } elseif ($node instanceof Node\Stmt\ClassMethod) { + $this->methodStack[] = ['name' => $node->name->toString(), 'isStatic' => $node->isStatic()]; + $this->thisAvailableStack[] = ! $node->isStatic(); + } elseif ($node instanceof Node\Stmt\Function_) { + $this->functionStack[] = $this->resolveQualifiedName($node->name) ?? ''; + $this->thisAvailableStack[] = false; + } elseif ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { + $this->thisAvailableStack[] = ! $node->static; + } + } + + private function trackDeclarationExit(Node $node): void + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->currentNamespace = ''; + } elseif ( + $node instanceof Node\Stmt\Class_ + || $node instanceof Node\Stmt\Interface_ + || $node instanceof Node\Stmt\Trait_ + || $node instanceof Node\Stmt\Enum_ + ) { + array_pop($this->classStack); + } elseif ($node instanceof Node\Stmt\ClassMethod) { + array_pop($this->methodStack); + array_pop($this->thisAvailableStack); + } elseif ($node instanceof Node\Stmt\Function_) { + array_pop($this->functionStack); + array_pop($this->thisAvailableStack); + } elseif ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { + array_pop($this->thisAvailableStack); + } + } + + private function enterClassLike(Node\Stmt\Class_|Node\Stmt\Interface_|Node\Stmt\Trait_|Node\Stmt\Enum_ $node): void + { + $typeName = $this->resolveQualifiedName($node->name); + $hasInheritance = true; + $hasPropertyWithDoc = false; + + if ($node instanceof Node\Stmt\Class_) { + $hasExtends = $node->extends !== null; + $hasImplements = $node->implements !== []; + $hasTraits = false; + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\TraitUse) { + $hasTraits = true; + } elseif ($stmt instanceof Node\Stmt\Property && $stmt->getDocComment() !== null) { + $hasPropertyWithDoc = true; + } } - $node->setAttribute('typephp_wrapped', value: true); - - return new Node\Expr\FuncCall( - new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::cloneInstance'), - [ - new Node\Arg( - new Node\Expr\Clone_( - new Node\Expr\FuncCall( - new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::prepareClone'), - [new Node\Arg($node->expr)] - ) - ) - ), - new Node\Arg($node->expr), - ] + $doc = $node->getDocComment(); + $hasClassDoc = $doc !== null && ( + str_contains($doc->getText(), '@template') + || str_contains($doc->getText(), '@phpstan-') + || str_contains($doc->getText(), '@psalm-') ); + + $hasInheritance = $hasExtends || $hasImplements || $hasTraits || $hasClassDoc; + } elseif ($node instanceof Node\Stmt\Enum_) { + $hasInheritance = $node->implements !== []; + } + + $this->classStack[] = [ + 'name' => $typeName, + 'isAnonymous' => ($node instanceof Node\Stmt\Class_ && $node->name === null), + 'hasInheritance' => $hasInheritance, + 'hasPropertyWithDoc' => $hasPropertyWithDoc, + ]; + } + + private function handleReturn(Node\Stmt\Return_ $node): void + { + if ($node->expr === null) { + return; } - if ( - $node instanceof Node\Stmt\Function_ + $doc = $node->getDocComment(); + if ($doc === null || ! str_contains($doc->getText(), '@var')) { + return; + } + + $extracted = DocblockExtractor::extractVarTagFromDoc($doc->getText()); + if ($extracted === null) { + return; + } + + [$typeString, $varName] = $extracted; + $isApplicable = ($varName === '') + || ($node->expr instanceof Node\Expr\Variable && $node->expr->name === $varName); + + if ($isApplicable) { + $node->expr = $this->wrapVariableCheck( + $node->expr, + $typeString, + $varName !== '' ? $varName : 'return', + $node->getStartLine() + ); + $node->setAttribute('typephp_var_wrapped', true); + } + } + + /** + * @return array|null + */ + private function handleExpression(Node\Stmt\Expression $node): ?array + { + $doc = $node->getDocComment(); + if ($doc !== null && str_contains($doc->getText(), '@var')) { + $this->scopeManager->extractVarDocblock($doc->getText(), $node->expr); + } + + if (! ($node->expr instanceof Node\Expr\Assign) || ! $this->isDestructuring($node->expr->var)) { + return null; + } + + /** @var Node\Expr\List_|Node\Expr\Array_ $destructuringVar */ + $destructuringVar = $node->expr->var; + $destructuredVars = $this->extractDestructuringVariables($destructuringVar); + $checkStmts = []; + + foreach ($destructuredVars as $dVar) { + $varName = $dVar['varName']; + $typeString = $this->scopeManager->getVarTypeFromScope($varName); + + if ($typeString !== null) { + $checkStmt = new Node\Stmt\Expression( + new Node\Expr\Assign( + $dVar['expr'], + $this->wrapVariableCheck($dVar['expr'], $typeString, $varName, $dVar['expr']->getStartLine()) + ) + ); + $checkStmt->setAttribute('typephp_injected', true); + $checkStmts[] = $checkStmt; + } + } + + return $checkStmts !== [] ? array_merge([$node], $checkStmts) : null; + } + + private function handleAssign(Node\Expr\Assign $node): void + { + if ($node->var instanceof Node\Expr\Variable && \is_string($node->var->name)) { + $varName = $node->var->name; + $typeString = $this->scopeManager->getVarTypeFromScope($varName); + + if ($typeString !== null) { + $node->expr = $this->wrapVariableCheck($node->expr, $typeString, $varName, $node->var->getStartLine()); + } + } elseif ($node->var instanceof Node\Expr\PropertyFetch && $node->var->name instanceof Node\Identifier) { + $node->expr = $this->wrapPropertyCheck($node->expr, $node->var->var, $node->var->name->toString(), $node->var->getStartLine()); + } elseif ($node->var instanceof Node\Expr\StaticPropertyFetch && $node->var->name instanceof Node\VarLikeIdentifier) { + $classArg = $node->var->class instanceof Node\Name + ? new Node\Expr\ClassConstFetch($node->var->class, 'class') + : $node->var->class; + + $node->expr = $this->wrapPropertyCheck($node->expr, $classArg, $node->var->name->toString(), $node->var->getStartLine()); + } + } + + private function wrapVariableCheck(Node\Expr $expr, string $typeString, string $varName, int $line): Node\Expr\Ternary + { + $checkCall = NodeBuilder::createVariableCheckCall( + $expr, + $typeString, + $varName, + $this->getCurrentCallerExpr(), + $this->getCurrentThisExpr() + ); + + return NodeBuilder::createTernaryThrowExpr($checkCall, $line); + } + + private function wrapPropertyCheck(Node\Expr $valueExpr, Node\Expr $targetExpr, string $propName, int $line): Node\Expr\Ternary + { + $checkCall = NodeBuilder::createPropertyCheckCall($valueExpr, $targetExpr, $propName); + + return NodeBuilder::createTernaryThrowExpr($checkCall, $line); + } + + private function wrapClone(Node\Expr\Clone_ $node): ?Node\Expr\FuncCall + { + if ($node->getAttribute('typephp_wrapped') === true) { + return null; + } + + $node->setAttribute('typephp_wrapped', true); + + return new Node\Expr\FuncCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::cloneInstance'), + [ + new Node\Arg( + new Node\Expr\Clone_( + new Node\Expr\FuncCall( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::prepareClone'), + [new Node\Arg($node->expr)] + ) + ) + ), + new Node\Arg($node->expr), + ] + ); + } + + private function isScopeBoundary(Node $node): bool + { + return $node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction @@ -189,18 +341,64 @@ public function leaveNode(Node $node): Node|null || $node instanceof Node\Stmt\While_ || $node instanceof Node\Stmt\For_ || $node instanceof Node\Stmt\Do_ - || $node instanceof Node\Stmt\TryCatch - ) { - $this->scopeManager->popScope(); + || $node instanceof Node\Stmt\TryCatch; + } + + private function isDestructuring(Node\Expr $expr): bool + { + return $expr instanceof Node\Expr\List_ + || ($expr instanceof Node\Expr\Array_ && $expr->getAttribute('kind') === Node\Expr\Array_::KIND_SHORT); + } + + private function resolveQualifiedName(?Node\Identifier $name): ?string + { + if ($name === null) { + return null; } - return null; + return $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $name->toString() + : $name->toString(); + } + + private function getCurrentCallerExpr(): Node\Expr + { + if ($this->methodStack !== [] && $this->classStack !== []) { + $classInfo = end($this->classStack); + $methodInfo = end($this->methodStack); + + if (! $classInfo['isAnonymous'] && $classInfo['name'] !== null) { + return new Node\Scalar\String_($classInfo['name'] . '::' . $methodInfo['name']); + } + + return new Node\Expr\BinaryOp\Concat( + new Node\Scalar\MagicConst\Class_(), + new Node\Scalar\String_('::' . $methodInfo['name']) + ); + } + + if ($this->functionStack !== []) { + return new Node\Scalar\String_(end($this->functionStack)); + } + + return new Node\Scalar\String_(''); + } + + private function getCurrentThisExpr(): Node\Expr + { + $hasThis = $this->classStack !== [] + && $this->thisAvailableStack !== [] + && end($this->thisAvailableStack); + + return $hasThis + ? new Node\Expr\Variable('this') + : new Node\Expr\ConstFetch(new Node\Name('null')); } /** * Recursively extracts target variables assigned inside a list() or [] destructuring node. * - * @return array + * @return list */ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ $listNode): array { diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index a26161a..d01d392 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -4,6 +4,7 @@ namespace TypePHP\Internal\Ast; +use PhpParser\Comment\Doc; use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; @@ -13,37 +14,74 @@ */ final class FunctionContractInjector { - public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): void + private const ITERABLE_TYPES = [ + 'iterable' => true, + 'traversable' => true, + 'generator' => true, + 'iterator' => true, + 'iteratoraggregate' => true, + ]; + + private const CALLABLE_TYPES = [ + 'callable' => true, + 'closure' => true, + ]; + + /** + * @param array{hasInheritance?: bool, hasPropertyWithDoc?: bool}|null $classContext + */ + public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ?array $classContext = null): void { if ($node->stmts === null) { return; } $isClassMethod = $node instanceof Node\Stmt\ClassMethod; - $doc = $node->getDocComment(); - - if ($doc === null && ! $isClassMethod) { - return; - } - + $doc = self::resolveDocComment($node); $docText = $doc !== null ? $doc->getText() : ''; + $hasInheritance = $classContext['hasInheritance'] ?? true; + $hasPropertyWithDoc = $classContext['hasPropertyWithDoc'] ?? true; + $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; + $isConstructor = $isClassMethod && $methodName === '__construct'; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); $isNativeNever = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'never'; - - $thisArg = self::resolveThisArg($isClassMethod, $node); $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; - $needsReturnVars = $isClassMethod || str_contains($docText, ' is ') || (str_contains($docText, '@return') && str_contains($docText, '$')); + $isPrivate = $isClassMethod && $node->isPrivate(); + + $paramCount = \count($node->params); + $hasParam = self::hasParamContracts( + $docText, + $isClassMethod, + $hasInheritance, + $paramCount, + $isPrivate, + $isConstructor, + $hasPropertyWithDoc, + $classContext === null, + $node->attrGroups !== [] + ); + + $hasReturnDoc = str_contains($docText, '@return') + || str_contains($docText, '@phpstan-return') + || str_contains($docText, '@psalm-return'); - $hasParam = self::hasParamContracts($docText, $isClassMethod) || $needsReturnVars; - $hasReturn = ! $isMagicLifecycle && ! $isNativeNever && self::hasReturnContracts($docText, $isClassMethod); + $hasReturn = ! $isMagicLifecycle + && ! $isNativeNever + && ! ($isNativeVoid && ! $hasReturnDoc) + && self::hasReturnContracts($docText, $isClassMethod, $isPrivate); if (! $hasParam && ! $hasReturn) { return; } + $thisArg = self::resolveThisArg($isClassMethod, $node); + $needsReturnVars = $hasParam && ($paramCount > 0) && ( + $hasInheritance || str_contains($docText, ' is ') || ($hasReturnDoc && str_contains($docText, '$')) + ); + $injectedStmts = []; if ($hasParam) { $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg); @@ -58,13 +96,64 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $node->stmts = [...$injectedStmts, ...$node->stmts]; } - private static function hasParamContracts(string $docText, bool $isClassMethod): bool + private static function resolveDocComment(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): ?Doc { - if ($isClassMethod) { + $doc = $node->getDocComment(); + if ($doc !== null) { + return $doc; + } + + foreach ($node->getComments() as $comment) { + if ($comment instanceof Doc) { + return $comment; + } + } + + if ($node->attrGroups !== []) { + foreach ($node->attrGroups as $group) { + $groupDoc = $group->getDocComment(); + if ($groupDoc !== null) { + return $groupDoc; + } + foreach ($group->getComments() as $comment) { + if ($comment instanceof Doc) { + return $comment; + } + } + } + } + + return null; + } + + private static function hasParamContracts( + string $docText, + bool $isClassMethod, + bool $hasInheritance, + int $paramCount, + bool $isPrivate, + bool $isConstructor, + bool $hasPropertyWithDoc, + bool $isDirectUnitTest, + bool $hasAttributes = false + ): bool { + if ($paramCount === 0 && ! str_contains($docText, '@template')) { + return $isDirectUnitTest && $isClassMethod; + } + + if ($isDirectUnitTest && $isClassMethod) { return true; } - if (! str_contains($docText, '@param') && ! str_contains($docText, '@phpstan-param') && ! str_contains($docText, '@psalm-param') && ! str_contains($docText, '@template')) { + if ($isConstructor && $docText === '' && ! $hasPropertyWithDoc) { + return false; + } + + if ($isPrivate && $docText === '') { + return false; + } + + if (! $hasInheritance && $docText === '' && ! ($isConstructor && $hasPropertyWithDoc) && ! $hasAttributes) { return false; } @@ -72,52 +161,69 @@ private static function hasParamContracts(string $docText, bool $isClassMethod): return true; } - if ((int) preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches) > 0) { - foreach ($matches[1] as $typeStr) { - $unionParts = explode('|', $typeStr); - $hasMixed = false; - foreach ($unionParts as $part) { - if (strtolower(trim($part)) === 'mixed') { - $hasMixed = true; + if (str_contains($docText, '@param')) { + return self::hasNonMixedParam($docText); + } - break; - } - } + return $isClassMethod && ! $isPrivate; + } - if (! $hasMixed) { - return true; + private static function hasNonMixedParam(string $docText): bool + { + if ((int) preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches) === 0) { + return false; + } + + foreach ($matches[1] as $typeStr) { + $unionParts = explode('|', $typeStr); + $hasMixed = false; + foreach ($unionParts as $part) { + if (strtolower(trim($part)) === 'mixed') { + $hasMixed = true; + + break; } } - return false; + if (! $hasMixed) { + return true; + } } return false; } - private static function hasReturnContracts(string $docText, bool $isClassMethod): bool - { - if ($isClassMethod) { - return true; + private static function hasReturnContracts( + string $docText, + bool $isClassMethod, + bool $isPrivate + ): bool { + if ($isPrivate && $docText === '') { + return false; } - if (str_contains($docText, '@template') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')) { + if ( + str_contains($docText, '@template') + || str_contains($docText, '@phpstan-return') + || str_contains($docText, '@psalm-return') + || str_contains($docText, '$this') + ) { return true; } - if (preg_match('/@return\s+([^\s$]+)/', $docText, $matches) === 1) { + if (preg_match('/@return\s+([^\s]+)/', $docText, $matches) === 1) { $returnTypeStr = $matches[1]; $unionParts = explode('|', $returnTypeStr); foreach ($unionParts as $part) { if (strtolower(trim($part)) === 'mixed') { - return false; // Collapses to mixed + return false; } } return true; } - return false; + return $isClassMethod && ! $isPrivate; } private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_|Node\Stmt\ClassMethod $node): Node\Expr @@ -176,8 +282,8 @@ private static function buildParamInjections( ): array { $injectedStmts = [self::buildSetupScopeStmt($params, $thisArg)]; - $callableWrappers = self::buildCallableParamWrappers($params, $docText, $thisArg); - $iterableWrappers = self::buildIterableParamWrappers($params, $docText, $thisArg); + $callableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isCallableCandidate'], 'wrapCallable'); + $iterableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isIterableCandidate'], 'wrapIterable'); return [...$injectedStmts, ...$callableWrappers, ...$iterableWrappers]; } @@ -229,53 +335,26 @@ private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg): /** * @param array $params + * @param callable(Node\Param, string): bool $predicate * * @return array */ - private static function buildCallableParamWrappers(array $params, string $docText, Node\Expr $thisArg): array - { - $wrappers = []; - foreach ($params as $param) { - if (self::isCallableCandidate($param, $docText) && $param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { - $paramName = $param->var->name; - $expr = new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\Variable($paramName), - new Node\Expr\FuncCall( - new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::wrapCallable'), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Scalar\String_($paramName)), - new Node\Arg(new Node\Expr\Variable($paramName)), - new Node\Arg($thisArg), - ] - ) - ) - ); - $expr->setAttribute('typephp_injected', true); - $wrappers[] = $expr; - } - } - - return $wrappers; - } - - /** - * @param array $params - * - * @return array - */ - private static function buildIterableParamWrappers(array $params, string $docText, Node\Expr $thisArg): array - { + private static function buildParamWrappers( + array $params, + string $docText, + Node\Expr $thisArg, + callable $predicate, + string $wrapperMethod + ): array { $wrappers = []; foreach ($params as $param) { - if (self::isIterableCandidate($param, $docText) && $param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { + if ($predicate($param, $docText) && $param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { $paramName = $param->var->name; $expr = new Node\Stmt\Expression( new Node\Expr\Assign( new Node\Expr\Variable($paramName), new Node\Expr\FuncCall( - new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::wrapIterable'), + new Node\Name\FullyQualified("TypePHP\\Internal\\RuntimeTypeChecker::{$wrapperMethod}"), [ new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg(new Node\Scalar\String_($paramName)), @@ -304,62 +383,39 @@ private static function isCallableCandidate(Node\Param $param, string $docText): return true; } - if ($param->type instanceof Node\Identifier) { - return strtolower($param->type->name) === 'callable'; - } + return self::typeMatchesName($param->type, self::CALLABLE_TYPES); + } - if ($param->type instanceof Node\Name) { - return strtolower($param->type->getLast()) === 'closure'; + private static function isIterableCandidate(Node\Param $param, string $docText): bool + { + if (self::typeMatchesName($param->type, self::ITERABLE_TYPES)) { + return true; } - if ($param->type instanceof Node\UnionType || $param->type instanceof Node\IntersectionType) { - foreach ($param->type->types as $t) { - if ($t instanceof Node\Identifier && strtolower($t->name) === 'callable') { - return true; - } - if ($t instanceof Node\Name && strtolower($t->getLast()) === 'closure') { - return true; - } - } + $paramName = $param->var instanceof Node\Expr\Variable && \is_string($param->var->name) ? $param->var->name : ''; + if ($paramName !== '' && preg_match('/@(?:param|phpstan-param|psalm-param)\s+[^\$]*?(?:iterable|Traversable|Generator|Iterator)\b[^\$]*?\$' . preg_quote($paramName, '/') . '\b/i', $docText) === 1) { + return true; } return false; } - private static function isIterableCandidate(Node\Param $param, string $docText): bool + /** + * @param array $targetNames + */ + private static function typeMatchesName(Node\Identifier|Node\Name|Node\ComplexType|null $type, array $targetNames): bool { - if ( - str_contains($docText, 'iterable') - || str_contains($docText, 'Traversable') - || str_contains($docText, 'Generator') - || str_contains($docText, 'Iterator') - || str_contains($docText, 'IteratorAggregate') - ) { - return true; + if ($type instanceof Node\Identifier) { + return isset($targetNames[strtolower($type->name)]); } - $iterableTypes = [ - 'iterable' => true, - 'traversable' => true, - 'generator' => true, - 'iterator' => true, - 'iteratoraggregate' => true, - ]; - - if ($param->type instanceof Node\Identifier) { - return isset($iterableTypes[strtolower($param->type->name)]); + if ($type instanceof Node\Name) { + return isset($targetNames[strtolower($type->getLast())]); } - if ($param->type instanceof Node\Name) { - return isset($iterableTypes[strtolower($param->type->getLast())]); - } - - if ($param->type instanceof Node\UnionType || $param->type instanceof Node\IntersectionType) { - foreach ($param->type->types as $t) { - if ($t instanceof Node\Identifier && isset($iterableTypes[strtolower($t->name)])) { - return true; - } - if ($t instanceof Node\Name && isset($iterableTypes[strtolower($t->getLast())])) { + if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { + foreach ($type->types as $t) { + if (self::typeMatchesName($t, $targetNames)) { return true; } } @@ -368,30 +424,39 @@ private static function isIterableCandidate(Node\Param $param, string $docText): return false; } - public static function buildTypeErrorThrowStmt(Node\Expr $errorVar): Node\Stmt\Expression + public static function buildTypeErrorThrowExpr(Node\Expr $errorVar, ?int $line = null): Node\Expr\Throw_ { - return new Node\Stmt\Expression( - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorFactory'), - 'prepareException', + $args = [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name\FullyQualified('TypePHP\Exception\TypeError'), [ new Node\Arg( - new Node\Expr\New_( - new Node\Name\FullyQualified('TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall($errorVar, 'getMessage') - ), - ] - ) + new Node\Expr\MethodCall($errorVar, 'getMessage') ), ] ) + ), + ]; + + if ($line !== null) { + $args[] = new Node\Arg(new Node\Scalar\LNumber($line)); + } + + return new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorFactory'), + 'prepareException', + $args ) ); } + public static function buildTypeErrorThrowStmt(Node\Expr $errorVar): Node\Stmt\Expression + { + return new Node\Stmt\Expression(self::buildTypeErrorThrowExpr($errorVar)); + } + public static function buildReturnCheckCall(Node\Expr $exprToWrap, Node\Expr $thisArg, bool $needsReturnVars = false): Node\Expr\FuncCall { $varsArg = $needsReturnVars @@ -436,24 +501,7 @@ public static function buildTernaryReturnExpr(Node\Expr\FuncCall $checkCall): No new Node\Expr\Assign(new Node\Expr\Variable('__typephpRet'), $checkCall), new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name\FullyQualified('TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpRet'), 'getMessage') - ), - ] - ) - ), - ] - ) - ), + self::buildTypeErrorThrowExpr(new Node\Expr\Variable('__typephpRet')), new Node\Expr\Variable('__typephpRet') ); } @@ -475,25 +523,7 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi new Node\Expr\Assign(new Node\Expr\Variable('__typephpYld'), $checkYieldCall), new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name\FullyQualified('TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpYld'), 'getMessage') - ), - ] - ) - ), - new Node\Arg(new Node\Scalar\LNumber($n->getStartLine())), - ] - ) - ), + self::buildTypeErrorThrowExpr(new Node\Expr\Variable('__typephpYld'), $n->getStartLine()), new Node\Expr\Variable('__typephpYld') ); @@ -511,24 +541,7 @@ public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thi new Node\Expr\Assign(new Node\Expr\Variable('__typephpSnd'), $checkSendCall), new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name\FullyQualified('TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpSnd'), 'getMessage') - ), - ] - ) - ), - ] - ) - ), + self::buildTypeErrorThrowExpr(new Node\Expr\Variable('__typephpSnd')), new Node\Expr\Variable('__typephpSnd') ); } diff --git a/src/Internal/Ast/NodeBuilder.php b/src/Internal/Ast/NodeBuilder.php index 0fdde1a..d02fe97 100644 --- a/src/Internal/Ast/NodeBuilder.php +++ b/src/Internal/Ast/NodeBuilder.php @@ -24,16 +24,30 @@ public static function createPropertyCheckCall(Node\Expr $valueExpr, Node\Expr $ ); } - public static function createVariableCheckCall(Node\Expr $valueExpr, string $typeString, string $varName): Node\Expr\FuncCall - { + public static function createVariableCheckCall( + Node\Expr $valueExpr, + string $typeString, + string $varName, + ?Node\Expr $callerExpr = null, + ?Node\Expr $thisArg = null + ): Node\Expr\FuncCall { + $args = [ + new Node\Arg($valueExpr), + new Node\Arg(new Node\Scalar\String_($typeString)), + new Node\Arg(new Node\Scalar\String_($varName)), + new Node\Arg(new Node\Scalar\MagicConst\File()), + ]; + + if ($callerExpr !== null) { + $args[] = new Node\Arg($callerExpr); + if ($thisArg !== null) { + $args[] = new Node\Arg($thisArg); + } + } + return new Node\Expr\FuncCall( new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkVariable'), - [ - new Node\Arg($valueExpr), - new Node\Arg(new Node\Scalar\String_($typeString)), - new Node\Arg(new Node\Scalar\String_($varName)), - new Node\Arg(new Node\Scalar\MagicConst\File()), - ] + $args ); } diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 9f275d9..c28cdbf 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -48,11 +48,18 @@ final class InlineChecker private static array $parsedTypeNodeCache = []; /** - * Memoized cache for PHP internal function determinations. + * In-memory cache for resolved class contexts with static bounds. * - * @var array + * @var array */ - private static array $internalFunctionsCache = []; + private static array $resolvedClassContextCache = []; + + /** + * In-memory cache for properties known to have no DocBlock annotations. + * + * @var array + */ + public static array $nullPropertyCache = []; /** * Resets internal type node and function caches. Useful for test isolation. @@ -60,7 +67,8 @@ final class InlineChecker public static function reset(): void { self::$parsedTypeNodeCache = []; - self::$internalFunctionsCache = []; + self::$resolvedClassContextCache = []; + self::$nullPropertyCache = []; } /** @@ -113,8 +121,15 @@ public static function reset(): void /** * Evaluates inline variable validation dynamically based on configuration. */ - public static function checkVariable(mixed $value, string $typeString, string $varName, string $file, TypeValidatorRegistry $registry): mixed - { + public static function checkVariable( + mixed $value, + string $typeString, + string $varName, + string $file, + TypeValidatorRegistry $registry, + ?string $caller = null, + mixed $thisOrClass = null + ): mixed { $rawConfig = Config::get()['inline_vars'] ?? []; /** @var array $config */ $config = \is_array($rawConfig) ? $rawConfig : []; @@ -131,8 +146,8 @@ public static function checkVariable(mixed $value, string $typeString, string $v $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); } - if ($needsContext) { - $typeNode = self::resolveCallerContext($typeNode); + if ($needsContext && $caller !== null) { + $typeNode = self::resolveCallerContext($typeNode, $caller, $thisOrClass); } if (! self::shouldValidateType($typeNode, $config)) { @@ -203,6 +218,13 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } + $className = \is_string($objectOrClass) ? $objectOrClass : \get_class($objectOrClass); + $cacheKey = $className . '::$' . $propName; + + if (isset(self::$nullPropertyCache[$cacheKey])) { + return $value; + } + $rawConfig = Config::get()['inline_vars'] ?? []; /** @var array $config */ $config = \is_array($rawConfig) ? $rawConfig : []; @@ -211,10 +233,10 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - $className = \is_string($objectOrClass) ? $objectOrClass : \get_class($objectOrClass); - $typeNode = DocblockParser::parseProperty($className, $propName); if ($typeNode === null) { + self::$nullPropertyCache[$cacheKey] = true; + return $value; } @@ -255,113 +277,29 @@ private static function hasActiveInlineChecks(array $config): bool /** * Resolves caller class or function context and applies templates & type aliases to the AST. */ - private static function resolveCallerContext(TypeNode $typeNode): TypeNode - { - $frameInfo = self::findCallerFrame(); - - if ($frameInfo['functionName'] !== null) { - return self::resolveFunctionContext($typeNode, $frameInfo['functionName']); - } - - if ($frameInfo['className'] !== null) { - return self::resolveClassContext( - $typeNode, - $frameInfo['className'], - $frameInfo['methodName'], - $frameInfo['thisObj'] - ); - } - - return $typeNode; - } - - /** - * Inspects the backtrace to find the nearest non-internal caller frame. - * - * @return array{className: ?string, methodName: ?string, functionName: ?string, thisObj: ?object} - */ - private static function findCallerFrame(): array + private static function resolveCallerContext(TypeNode $typeNode, ?string $caller = null, mixed $thisOrClass = null): TypeNode { - $className = null; - $methodName = null; - $functionName = null; - $thisObj = null; - - $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 15); - - foreach ($trace as $frame) { - $classCandidate = $frame['class'] ?? null; - $funcCandidate = $frame['function']; - - if ($classCandidate === 'Closure' || $classCandidate === 'Generator') { - if ($thisObj === null && isset($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { - $thisObj = $frame['object']; - } - - continue; + if ($caller !== null) { + if ($caller === '') { + return $typeNode; } - if ($funcCandidate === '{closure}' || str_starts_with($funcCandidate, '{closure')) { - if ($thisObj === null && isset($frame['object']) && ! ($frame['object'] instanceof \Closure) && ! ($frame['object'] instanceof \Generator)) { - $thisObj = $frame['object']; - } + if (str_contains($caller, '::')) { + [$className, $methodName] = explode('::', $caller, 2); + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - continue; + return self::resolveClassContext( + $typeNode, + $className, + $methodName, + $thisObj + ); } - if ($classCandidate !== null) { - if (! str_starts_with($classCandidate, 'TypePHP\\Internal\\') && ! str_starts_with($classCandidate, 'TypePHP\\Wrapper\\')) { - $className = $classCandidate; - $methodName = $funcCandidate; - if ($thisObj === null) { - $thisObj = $frame['object'] ?? null; - } - - break; - } - } else { - if (! str_starts_with($funcCandidate, 'TypePHP\\')) { - if (! \in_array($funcCandidate, ['include', 'include_once', 'require', 'require_once', 'eval'], true)) { - if (self::isInternalFunction($funcCandidate)) { - continue; - } - - $functionName = $funcCandidate; - - break; - } - } - } - } - - return [ - 'className' => $className, - 'methodName' => $methodName, - 'functionName' => $functionName, - 'thisObj' => $thisObj, - ]; - } - - /** - * Fast check if a function name represents an internal PHP built-in function. - */ - private static function isInternalFunction(string $funcName): bool - { - if (! \function_exists($funcName)) { - return false; - } - - if (isset(self::$internalFunctionsCache[$funcName])) { - return self::$internalFunctionsCache[$funcName]; + return self::resolveFunctionContext($typeNode, $caller); } - try { - $rf = new \ReflectionFunction($funcName); - - return self::$internalFunctionsCache[$funcName] = $rf->isInternal(); - } catch (\ReflectionException $e) { - return self::$internalFunctionsCache[$funcName] = false; - } + return $typeNode; } /** @@ -408,6 +346,14 @@ private static function resolveClassContext( return $typeNode; } + $cacheKey = null; + if ($thisObj === null) { + $cacheKey = ((string) $typeNode) . '|' . $className . '|' . ($methodName ?? ''); + if (isset(self::$resolvedClassContextCache[$cacheKey])) { + return self::$resolvedClassContextCache[$cacheKey]; + } + } + try { /** @var class-string $className */ $refClass = new \ReflectionClass($className); @@ -415,14 +361,22 @@ private static function resolveClassContext( $classAliases = DocblockParser::parseClassAliases($className); - $targetFunc = ($methodName !== '{closure}' && $methodName !== null) + $targetFunc = ($methodName !== '{closure}' && $methodName !== null && ! str_starts_with($methodName, '{closure')) ? $className . '::' . $methodName : $className . '::__construct'; $contract = DocblockParser::parse($targetFunc); $declaredTemplates = $contract['allTemplates'] ?? ($contract['classTemplates'] ?? []); - $boundTemplates = TemplateManager::getBoundTemplates($targetFunc, $thisObj, $declaredTemplates); + if (\count($classAliases) === 0 && \count($declaredTemplates) === 0) { + if ($cacheKey !== null) { + return self::$resolvedClassContextCache[$cacheKey] = $typeNode; + } + + return $typeNode; + } + + $boundTemplates = TemplateManager::getBoundTemplates($targetFunc, $thisObj, $declaredTemplates); $activeBindings = [...$classAliases, ...$boundTemplates]; if (\count($activeBindings) > 0 || \count($declaredTemplates) > 0) { @@ -433,6 +387,10 @@ private static function resolveClassContext( // Silently continue if reflection fails } + if ($cacheKey !== null) { + return self::$resolvedClassContextCache[$cacheKey] = $typeNode; + } + return $typeNode; } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index e165e00..880672c 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -36,11 +36,77 @@ final class ParamChecker private static array $effectiveFunctionCache = []; /** - * Resets the effective function cache. Useful for test isolation. + * O(1) Fast-path cache for methods determined to have no parameter contracts. + * + * @var array + */ + public static array $noParamContractCache = []; + + /** + * Cache for resolved parameter base types (after alias and special type resolution). + * + * @var array + */ + private static array $baseTypeCache = []; + + /** + * Cache for whether all parameters of a function are unconstrained (mixed or array). + * + * @var array + */ + private static array $allParamsUnconstrainedCache = []; + + /** + * Resets internal caches. Useful for test isolation. */ public static function reset(): void { self::$effectiveFunctionCache = []; + self::$noParamContractCache = []; + ClassNameValidator::reset(); + self::$baseTypeCache = []; + self::$allParamsUnconstrainedCache = []; + } + + /** + * Checks if all parameters of a function are unconstrained (mixed or array). + * Uses memoization to avoid repeated docblock parsing. + */ + public static function areAllParamsUnconstrained(string $effectiveFunction): bool + { + if (str_contains($effectiveFunction, '__call')) { + return false; + } + + $cacheKey = $effectiveFunction . '|unconstrained'; + if (! isset(self::$allParamsUnconstrainedCache[$cacheKey])) { + $contract = DocblockParser::parse($effectiveFunction); + $allUnconstrained = true; + foreach ($contract['types'] as $typeNode) { + if (! self::isUnconstrained($typeNode)) { + $allUnconstrained = false; + + break; + } + } + self::$allParamsUnconstrainedCache[$cacheKey] = $allUnconstrained; + } + + return self::$allParamsUnconstrainedCache[$cacheKey]; + } + + /** + * Checks if a TypeNode represents an unconstrained type (mixed or array). + */ + private static function isUnconstrained(TypeNode $typeNode): bool + { + if (! ($typeNode instanceof IdentifierTypeNode)) { + return false; + } + + $lower = strtolower($typeNode->name); + + return $lower === 'mixed' || $lower === 'array'; } /** @@ -62,8 +128,13 @@ public static function checkParams( $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); } - // Fast-path: If no arguments were passed and it's not a magic __call dispatch, exit immediately - if ($vars === [] && ! str_ends_with($effectiveFunction, '::__call') && ! str_ends_with($effectiveFunction, '::__callStatic')) { + $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); + + if (! $isMagicCall && isset(self::$noParamContractCache[$effectiveFunction])) { + return null; + } + + if ($vars === [] && ! $isMagicCall) { return null; } @@ -72,55 +143,175 @@ public static function checkParams( return $magicError; } + if ($isMagicCall) { + return null; + } + $contract = DocblockParser::parse($effectiveFunction); if (! $contract['hasParamContract']) { + self::$noParamContractCache[$effectiveFunction] = true; + self::$noParamContractCache[$function] = true; + return null; } + $paramsUseGenerics = (bool) ($contract['paramsUseGenerics'] ?? true); $methodTemplates = $contract['templates']; $classTemplates = $contract['classTemplates'] ?? []; $aliases = $contract['aliases']; - $hasGenerics = (\count($methodTemplates) > 0 || \count($classTemplates) > 0); - - if (! $hasGenerics && \count($aliases) === 0) { - foreach ($contract['types'] as $paramName => $typeNode) { - if (\array_key_exists($paramName, $vars)) { - $err = $registry->validate($vars[$paramName], $typeNode, $effectiveFunction . '(): Argument $' . $paramName); - if ($err !== null) { - return $err; - } + $hasMethodTemplates = (\count($methodTemplates) > 0); + + if (! $paramsUseGenerics && ! $hasMethodTemplates && \count($aliases) === 0) { + return self::validateSimpleParams($contract['types'], $vars, $effectiveFunction, $registry); + } + + self::prepareGenericBindings($effectiveFunction, $methodTemplates, $thisObj, $classTemplates); + + /** @var array $allTemplates */ + $allTemplates = [...$classTemplates, ...$methodTemplates]; + + if (\count($allTemplates) > 0 && $paramsUseGenerics) { + self::preInferGenericTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates, $classTemplates); + } + + $boundTemplates = (\count($allTemplates) > 0) + ? TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates) + : []; + $declaredTemplates = $allTemplates; + + $baseTypes = self::resolveBaseTypes($contract['types'], $effectiveFunction, $thisObj, $aliases); + + return self::validateAllParameters( + $contract['types'], + $baseTypes, + $vars, + $effectiveFunction, + $thisObj, + $allTemplates, + $aliases, + $boundTemplates, + $declaredTemplates, + $registry, + $classTemplates + ); + } + + /** + * Validates simple parameters when no generics/aliases are involved. + * + * @param array $types + * @param array $vars + */ + private static function validateSimpleParams( + array $types, + array $vars, + string $effectiveFunction, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + foreach ($types as $paramName => $typeNode) { + if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { + if (self::isUnconstrained($typeNode)) { + continue; + } + $err = $registry->validate($vars[$paramName], $typeNode, ''); + if ($err !== null) { + return ErrorFactory::createError($effectiveFunction . '(): Argument $' . $paramName . $err->getMessage()); } } - - return null; } + return null; + } + + /** + * Prepares generic bindings: clears call bindings and resolves inherited templates. + * + * @param array $methodTemplates + * @param array $classTemplates + */ + private static function prepareGenericBindings( + string $effectiveFunction, + array $methodTemplates, + ?object $thisObj, + array $classTemplates + ): void { if (\count($methodTemplates) > 0) { TemplateManager::clearCallBindings($effectiveFunction, $methodTemplates); } - if ($thisObj !== null && \count($classTemplates) > 0 && str_contains($effectiveFunction, '::')) { + if ($thisObj !== null && \count($classTemplates) > 0 && ! TemplateManager::hasInstanceBindings($thisObj) && str_contains($effectiveFunction, '::')) { $declaringClass = explode('::', $effectiveFunction, 2)[0]; TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); } + } - $allTemplates = [...$classTemplates, ...$methodTemplates]; - if (\count($allTemplates) > 0) { - self::preInferGenericTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $allTemplates); + /** + * Pre‑resolves and caches base types for each parameter. + * + * @param array $types + * @param array $aliases + * + * @return array + */ + private static function resolveBaseTypes( + array $types, + string $effectiveFunction, + ?object $thisObj, + array $aliases + ): array { + $baseTypes = []; + foreach ($types as $paramName => $typeNode) { + $cacheKey = $effectiveFunction . '|' . $paramName; + if (! isset(self::$baseTypeCache[$cacheKey])) { + if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { + $typeNode = $aliases[$typeNode->name]; + } + $resolved = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); + self::$baseTypeCache[$cacheKey] = $resolved; + } + $baseTypes[$paramName] = self::$baseTypeCache[$cacheKey]; } - $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $allTemplates); - $declaredTemplates = $allTemplates; + return $baseTypes; + } - foreach ($contract['types'] as $paramName => $typeNode) { - if (! \array_key_exists($paramName, $vars)) { + /** + * Validates all parameters against their resolved base types. + * + * @param array $contractTypes + * @param array $baseTypes + * @param array $vars + * @param string $effectiveFunction + * @param object|null $thisObj + * @param array $allTemplates + * @param array $aliases + * @param array $boundTemplates + * @param array $declaredTemplates + * @param TypeValidatorRegistry $registry + * @param array $classTemplates + */ + private static function validateAllParameters( + array $contractTypes, + array $baseTypes, + array $vars, + string $effectiveFunction, + ?object $thisObj, + array $allTemplates, + array $aliases, + array $boundTemplates, + array $declaredTemplates, + TypeValidatorRegistry $registry, + array $classTemplates + ): ?ErrorMessage { + foreach ($contractTypes as $paramName => $_) { + if (! isset($vars[$paramName]) && ! \array_key_exists($paramName, $vars)) { continue; } $err = self::validateSingleParam( $paramName, - $typeNode, + $baseTypes[$paramName], $vars[$paramName], $effectiveFunction, $thisObj, @@ -128,7 +319,8 @@ public static function checkParams( $aliases, $boundTemplates, $declaredTemplates, - $registry + $registry, + $classTemplates ); if ($err !== null) { @@ -148,45 +340,46 @@ public static function resolveEffectiveFunction(string $function, object|string| return $function; } - $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : ''); + $actualClassName = \is_object($thisOrClass) ? $thisOrClass::class : (\is_string($thisOrClass) ? $thisOrClass : ''); if ($actualClassName === '') { return $function; } - [$classOrTrait, $methodName] = explode('::', $function, 2); + if (str_starts_with($function, $actualClassName . '::') && HierarchyResolver::getTraitAliases($actualClassName) === []) { + return $function; + } $cacheKey = $function . '|' . $actualClassName; if (isset(self::$effectiveFunctionCache[$cacheKey])) { return self::$effectiveFunctionCache[$cacheKey]; } + [$classOrTrait, $methodName] = explode('::', $function, 2); + $effectiveFunction = ($actualClassName !== $classOrTrait) ? $actualClassName . '::' . $methodName : $function; if ($thisObj !== null) { - $targetClass = $actualClassName; - $traitAliases = HierarchyResolver::getTraitAliases($targetClass); + $traitAliases = HierarchyResolver::getTraitAliases($actualClassName); if (\count($traitAliases) > 0) { - $isPotentialAlias = isset($traitAliases[$methodName]); - if (! $isPotentialAlias) { - foreach ($traitAliases as $originalTarget) { - if (str_ends_with($originalTarget, '::' . $methodName)) { - $isPotentialAlias = true; + $isTargetOfAlias = false; + foreach ($traitAliases as $originalTarget) { + if (str_ends_with($originalTarget, '::' . $methodName)) { + $isTargetOfAlias = true; - break; - } + break; } } - if ($isPotentialAlias) { + if ($isTargetOfAlias) { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); foreach ($trace as $frame) { $frameFunc = $frame['function']; $frameClass = $frame['class'] ?? ''; if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { - return self::$effectiveFunctionCache[$cacheKey] = $targetClass . '::' . $frameFunc; + return self::$effectiveFunctionCache[$cacheKey] = $actualClassName . '::' . $frameFunc; } } } @@ -240,16 +433,32 @@ private static function handleMagicCall( * @param array $types * @param array $vars * @param array $templates + * @param array $classTemplates */ private static function preInferGenericTemplates( array $types, array $vars, string $effectiveFunction, ?object $thisObj, - array $templates + array $templates, + array $classTemplates = [] ): void { - self::inferTemplatesFromClosures($types, $vars, $effectiveFunction, $thisObj, $templates); - self::inferTemplatesFromArrays($types, $vars, $effectiveFunction, $thisObj, $templates); + $hasClosure = false; + foreach ($vars as $v) { + if ($v instanceof \Closure) { + $hasClosure = true; + + break; + } + } + + if ($hasClosure) { + self::inferTemplatesFromClosures($types, $vars, $effectiveFunction, $thisObj, $templates, $classTemplates); + } + + if (\count($types) > 1) { + self::inferTemplatesFromArrays($types, $vars, $effectiveFunction, $thisObj, $templates); + } } /** @@ -258,24 +467,23 @@ private static function preInferGenericTemplates( * @param array $types * @param array $vars * @param array $templates + * @param array $classTemplates */ private static function inferTemplatesFromClosures( array $types, array $vars, string $effectiveFunction, ?object $thisObj, - array $templates + array $templates, + array $classTemplates = [] ): void { $callableNodes = self::extractCallableNodes($types); if (\count($callableNodes) === 0) { return; } - $contract = DocblockParser::parse($effectiveFunction); - $classTemplates = $contract['classTemplates'] ?? []; - foreach ($callableNodes as $cParamName => $cTypeNode) { - if (! \array_key_exists($cParamName, $vars) || ! ($vars[$cParamName] instanceof \Closure)) { + if (! isset($vars[$cParamName]) || ! ($vars[$cParamName] instanceof \Closure)) { continue; } @@ -384,7 +592,7 @@ private static function inferTemplatesFromArrays( array $templates ): void { foreach ($types as $paramName => $typeNode) { - if (! \array_key_exists($paramName, $vars) || ! \is_array($vars[$paramName]) || \count($vars[$paramName]) === 0) { + if (! isset($vars[$paramName]) || ! \is_array($vars[$paramName]) || \count($vars[$paramName]) === 0) { continue; } @@ -531,6 +739,7 @@ private static function bindTemplateIfUnbound( * @param array $aliases * @param array $boundTemplates * @param array $declaredTemplates + * @param array $classTemplates */ private static function validateSingleParam( string $paramName, @@ -542,16 +751,11 @@ private static function validateSingleParam( array $aliases, array $boundTemplates, array $declaredTemplates, - TypeValidatorRegistry $registry + TypeValidatorRegistry $registry, + array $classTemplates = [] ): ?ErrorMessage { - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; - } - - $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); - - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; + if (self::isUnconstrained($typeNode)) { + return null; } $isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)); @@ -564,11 +768,11 @@ private static function validateSingleParam( } if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { - return self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates); + return self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $classTemplates); } if (self::getTemplateName($typeNode, $templates) !== null) { - return self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry); + return self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry, $classTemplates); } return $registry->validate($val, $typeNode, $effectiveFunction . '(): Argument $' . $paramName); @@ -691,6 +895,7 @@ private static function isClassStringTemplate(GenericTypeNode $typeNode, array $ /** * @param array $templates + * @param array $classTemplates */ private static function resolveClassStringTemplate( GenericTypeNode $typeNode, @@ -698,15 +903,14 @@ private static function resolveClassStringTemplate( string $paramName, string $function, ?object $thisObj, - array $templates + array $templates, + array $classTemplates = [] ): ?ErrorMessage { /** @var IdentifierTypeNode $innerType */ $innerType = $typeNode->genericTypes[0]; $templateName = $innerType->name; $templateNode = $templates[$templateName]; - $contract = DocblockParser::parse($function); - $classTemplates = $contract['classTemplates'] ?? []; $isClassLevelTemplate = isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; @@ -716,15 +920,19 @@ private static function resolveClassStringTemplate( } if ($templateNode->bound !== null) { - $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); - if (! self::checkClassStringSatisfiesBound($val, $resolvedBound)) { - $boundDisplay = (string) $resolvedBound; + $boundLower = $templateNode->bound instanceof IdentifierTypeNode ? strtolower($templateNode->bound->name) : ''; + if ($boundLower !== 'object' && $boundLower !== 'mixed') { + $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); + if (! self::checkClassStringSatisfiesBound($val, $resolvedBound)) { + $boundDisplay = (string) $resolvedBound; - return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundDisplay . ", '" . $val . "' given"); + return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundDisplay . ", '" . $val . "' given"); + } } } - TemplateManager::bindTemplate($function, $targetObj, $templateName, new IdentifierTypeNode($val)); + $classNode = TemplateManager::$classNodeCache[$val] ??= new IdentifierTypeNode($val); + TemplateManager::bindTemplate($function, $targetObj, $templateName, $classNode); } else { $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); if ($expectedTypeNode !== null) { @@ -810,6 +1018,7 @@ private static function getTemplateName(TypeNode $typeNode, array $templates): ? /** * @param array $templates + * @param array $classTemplates */ private static function resolveTemplateParam( TypeNode $typeNode, @@ -818,7 +1027,8 @@ private static function resolveTemplateParam( string $function, ?object $thisObj, array $templates, - TypeValidatorRegistry $registry + TypeValidatorRegistry $registry, + array $classTemplates = [] ): ?ErrorMessage { $templateName = self::getTemplateName($typeNode, $templates); if ($templateName === null || ! isset($templates[$templateName])) { @@ -829,8 +1039,6 @@ private static function resolveTemplateParam( $isVariadic = $typeNode instanceof ArrayTypeNode; $isNullable = ($typeNode instanceof NullableTypeNode) || ($typeNode instanceof UnionTypeNode && self::typeContainsNull($typeNode)); - $contract = DocblockParser::parse($function); - $classTemplates = $contract['classTemplates'] ?? []; $isClassLevelTemplate = isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 506e756..51354e1 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -13,6 +13,7 @@ use PHPStan\PhpDocParser\Ast\Type\TypeNode; use ReflectionClass; use Traversable; +use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; @@ -28,16 +29,77 @@ final class ReturnChecker { /** - * @var array + * O(1) Fast-path cache for methods determined to have no return contracts. + * + * @var array + */ + public static array $noReturnContractCache = []; + + /** + * In-memory cache for unbound generic return types. + * + * @var array + */ + public static array $unboundReturnCache = []; + + /** + * Memoized static return type resolutions for non-dynamic, non-generic methods. + * + * @var array */ - private static array $effectiveFunctionCache = []; + private static array $resolvedStaticReturnCache = []; /** - * Resets the effective function cache. Useful for test isolation. + * In-memory cache for concrete substituted generic return types. + * + * @var array + */ + public static array $substitutedReturnCache = []; + + /** + * Cache for whether a return type is unconstrained (mixed or array). + * + * @var array + */ + private static array $returnUnconstrainedCache = []; + + /** + * Resets internal caches. Useful for test isolation. */ public static function reset(): void { - self::$effectiveFunctionCache = []; + self::$noReturnContractCache = []; + self::$resolvedStaticReturnCache = []; + self::$unboundReturnCache = []; + self::$substitutedReturnCache = []; + self::$returnUnconstrainedCache = []; + } + + /** + * Checks if the return type of a function is unconstrained (mixed or array). + * Uses memoization to avoid repeated docblock parsing. + */ + public static function isReturnUnconstrained(string $effectiveFunction): bool + { + if (str_contains($effectiveFunction, '__call')) { + return false; + } + + $cacheKey = $effectiveFunction . '|return_unconstrained'; + if (! isset(self::$returnUnconstrainedCache[$cacheKey])) { + $contract = DocblockParser::parse($effectiveFunction); + $returnNode = $contract['return'] ?? null; + $unconstrained = false; + if ($returnNode instanceof IdentifierTypeNode) { + $lower = strtolower($returnNode->name); + if ($lower === 'mixed' || $lower === 'array') { + $unconstrained = true; + } + } + self::$returnUnconstrainedCache[$cacheKey] = $unconstrained; + } + + return self::$returnUnconstrainedCache[$cacheKey]; } /** @@ -55,8 +117,20 @@ public static function checkReturn( return $value; } + if (isset(self::$noReturnContractCache[$function])) { + return $value; + } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (isset(self::$noReturnContractCache[$effectiveFunction])) { + self::$noReturnContractCache[$function] = true; + + return $value; + } + + $isMagicCall = str_contains($effectiveFunction, '__call'); $magicResult = self::handleMagicReturn( $effectiveFunction, @@ -71,14 +145,24 @@ public static function checkReturn( return $magicResult; } + if ($isMagicCall) { + return $value; + } + $contract = DocblockParser::parse($effectiveFunction); if (! ($contract['hasReturnContract'] ?? ($contract['return'] !== null))) { + self::$noReturnContractCache[$effectiveFunction] = true; + self::$noReturnContractCache[$function] = true; + return $value; } $returnTypeNode = $contract['return']; if ($returnTypeNode === null) { + self::$noReturnContractCache[$effectiveFunction] = true; + self::$noReturnContractCache[$function] = true; + return $value; } @@ -93,67 +177,11 @@ public static function checkReturn( $contract['aliases'] ?? [], $allTemplates, $registry, - $wrapIterableCallback + $wrapIterableCallback, + $contract ); } - /** - * Resolves the actual runtime class name vs trait name with O(1) memoization. - */ - private static function resolveEffectiveFunction(string $function, object|string|null $thisOrClass, ?object $thisObj): string - { - if (! str_contains($function, '::')) { - return $function; - } - - $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : ''); - if ($actualClassName === '') { - return $function; - } - - [$classOrTrait, $methodName] = explode('::', $function, 2); - - $cacheKey = $function . '|' . $actualClassName; - if (isset(self::$effectiveFunctionCache[$cacheKey])) { - return self::$effectiveFunctionCache[$cacheKey]; - } - - $effectiveFunction = ($actualClassName !== $classOrTrait) - ? $actualClassName . '::' . $methodName - : $function; - - if ($thisObj !== null) { - $targetClass = $actualClassName; - $traitAliases = HierarchyResolver::getTraitAliases($targetClass); - - if (\count($traitAliases) > 0) { - $isPotentialAlias = isset($traitAliases[$methodName]); - if (! $isPotentialAlias) { - foreach ($traitAliases as $originalTarget) { - if (str_ends_with($originalTarget, '::' . $methodName)) { - $isPotentialAlias = true; - - break; - } - } - } - - if ($isPotentialAlias) { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); - foreach ($trace as $frame) { - $frameFunc = $frame['function']; - $frameClass = $frame['class'] ?? ''; - if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { - return self::$effectiveFunctionCache[$cacheKey] = $targetClass . '::' . $frameFunc; - } - } - } - } - } - - return self::$effectiveFunctionCache[$cacheKey] = $effectiveFunction; - } - /** * Intercepts and evaluates return contracts for dynamic @method calls routed via __call and __callStatic. * @@ -212,6 +240,15 @@ private static function handleMagicReturn( * @param array $vars * @param array $aliases * @param array $templates + * @param array{ + * return?: TypeNode|null, + * hasReturnContract?: bool, + * returnIsThis?: bool, + * returnIsDynamic?: bool, + * aliases?: array, + * templates?: array, + * classTemplates?: array + * } $contract */ private static function evaluateReturn( TypeNode $returnTypeNode, @@ -222,22 +259,39 @@ private static function evaluateReturn( array $aliases, array $templates, TypeValidatorRegistry $registry, - callable $wrapIterableCallback + callable $wrapIterableCallback, + array $contract = [] ): mixed { - $err = SpecialTypeResolver::checkThisIdentity($returnTypeNode, $value, $thisObj, $function); - if ($err !== null) { - return $err; + if ($contract['returnIsThis'] ?? false) { + $err = SpecialTypeResolver::checkThisIdentity($returnTypeNode, $value, $thisObj, $function); + if ($err !== null) { + return $err; + } } $hasGenerics = (\count($templates) > 0); $hasAliases = (\count($aliases) > 0); - $isConditional = ($returnTypeNode instanceof ConditionalTypeForParameterNode || $returnTypeNode instanceof ConditionalTypeNode); + $isParamConditional = ($returnTypeNode instanceof ConditionalTypeForParameterNode); - if (! $hasGenerics && ! $hasAliases && ! $isConditional && ! ($returnTypeNode instanceof CallableTypeNode)) { - $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); - $err = $registry->validate($value, $resolvedType, $function . '(): Return value'); + if (! $hasGenerics && ! $hasAliases && ! $isParamConditional && ! ($returnTypeNode instanceof CallableTypeNode)) { + $isDynamic = $contract['returnIsDynamic'] ?? (str_contains((string) $returnTypeNode, 'static') || str_contains((string) $returnTypeNode, '$this')); + + if (! $isDynamic) { + $resolvedType = self::$resolvedStaticReturnCache[$function] ??= SpecialTypeResolver::resolve($returnTypeNode, $function, null); + } else { + $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); + } + + if ($resolvedType instanceof IdentifierTypeNode) { + $lower = strtolower($resolvedType->name); + if ($lower === 'mixed' || $lower === 'array') { + return $value; + } + } + + $err = $registry->validate($value, $resolvedType, 'Return value'); if ($err !== null) { - return $err; + return ErrorFactory::createError($function . '(): ' . $err->getMessage()); } if ($value instanceof Traversable) { @@ -257,24 +311,55 @@ private static function evaluateReturn( return $value; } - $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); - if ($resolvedType instanceof IdentifierTypeNode && isset($aliases[$resolvedType->name])) { - $resolvedType = $aliases[$resolvedType->name]; + $cacheKey = null; + if (\count($boundTemplates) <= 2 && ! $isParamConditional && \count($aliases) === 0 && $thisObj === null) { + $cacheKey = $function; + foreach ($boundTemplates as $k => $v) { + $cacheKey .= '|' . $k . ':' . ($v instanceof IdentifierTypeNode ? $v->name : (string) $v); + } + if (isset(self::$substitutedReturnCache[$cacheKey])) { + $resolvedType = self::$substitutedReturnCache[$cacheKey]; + } } - $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + if (! isset($resolvedType)) { + $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); - if (\count($boundTemplates) > 0 || \count($templates) > 0) { - $resolvedType = TemplateSubstitutor::substitute($resolvedType, $boundTemplates, $templates); - $resolvedType = SpecialTypeResolver::resolve($resolvedType, $function, $thisObj); + if ($resolvedType instanceof IdentifierTypeNode && isset($aliases[$resolvedType->name])) { + $resolvedType = $aliases[$resolvedType->name]; + } + + if (\count($boundTemplates) === 0 && \count($templates) > 0 && ! $isParamConditional && $thisObj === null) { + $resolvedType = self::$unboundReturnCache[$function] ??= SpecialTypeResolver::resolve( + TemplateSubstitutor::substitute($returnTypeNode, [], $templates), + $function, + null + ); + } elseif (\count($boundTemplates) > 0 || \count($templates) > 0) { + $resolvedType = TemplateSubstitutor::substitute($resolvedType, $boundTemplates, $templates); + $resolvedType = SpecialTypeResolver::resolve($resolvedType, $function, $thisObj); + } + + $resolvedType = self::resolveConditionalReturnType($resolvedType, $vars, $boundTemplates, $registry, $function); + + if ($cacheKey !== null) { + self::$substitutedReturnCache[$cacheKey] = $resolvedType; + } } - $resolvedType = self::resolveConditionalReturnType($resolvedType, $vars, $boundTemplates, $registry, $function); + // Fast-path: if return type is mixed or array, skip validation. + if ($resolvedType instanceof IdentifierTypeNode) { + $lower = strtolower($resolvedType->name); + if ($lower === 'mixed' || $lower === 'array') { + return $value; + } + } - $err = $registry->validate($value, $resolvedType, $function . '(): Return value'); + $err = $registry->validate($value, $resolvedType, 'Return value'); if ($err !== null) { - return $err; + return ErrorFactory::createError($function . '(): ' . $err->getMessage()); } if ($resolvedType instanceof CallableTypeNode && CallableWrapper::isCallable($value)) { @@ -338,7 +423,7 @@ private static function resolveParameterConditional( $paramName = ltrim($node->parameterName, '$'); $paramValue = null; - if (\array_key_exists($paramName, $vars)) { + if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { $paramValue = $vars[$paramName]; } elseif (\count($vars) > 0 && $function !== '' && str_contains($function, '::')) { $paramValue = self::resolveRenamedParamValue($function, $paramName, $vars); @@ -392,7 +477,7 @@ private static function resolveRenamedParamValue(string $function, string $param if ($targetIndex !== null) { $values = array_values($vars); - if (\array_key_exists($targetIndex, $values)) { + if (isset($values[$targetIndex]) || \array_key_exists($targetIndex, $values)) { return $values[$targetIndex]; } } diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index af9fdd0..4249999 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -64,6 +64,20 @@ private static function getTemplate(): string 'params' => true, 'returns' => true, + /* + |-------------------------------------------------------------------------- + | Strict Generic Return Invariance (PHPStan / Psalm Parity) + |-------------------------------------------------------------------------- + | When true (default / strict), generic return types enforce invariance + | matching PHPStan Level MAX. Returning Collection when Collection + | is promised will be rejected unless the class declares '@template-covariant' + | or the return type specifies use-site covariance ''. + | + | Set to false (pragmatic mode) when integrating with frameworks like Shopware, + | Laravel, or legacy codebases where collection classes omit '@template-covariant'. + */ + 'strict_return_generic_invariance' => true, + /* |-------------------------------------------------------------------------- | Magic Annotations (@property & @method) diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 34e74d2..5d76d37 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -37,7 +37,7 @@ final class DocblockParser /** * Cache for resolved contract metadata. * - * @var array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool}> + * @var array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool, paramsUseGenerics: bool, returnUsesGenerics: bool}> */ private static array $cache = []; @@ -145,10 +145,93 @@ public static function reset(): void SpecialTypeResolver::reset(); } + /** + * Checks recursively whether an AST TypeNode references any declared generic templates. + * + * @param array $templateNames + */ + public static function typeReferencesTemplate(?TypeNode $node, array $templateNames): bool + { + if ($node === null || $templateNames === []) { + return false; + } + + if ($node instanceof IdentifierTypeNode) { + return isset($templateNames[$node->name]); + } + + if ($node instanceof GenericTypeNode) { + if (isset($templateNames[$node->type->name])) { + return true; + } + foreach ($node->genericTypes as $gt) { + if (self::typeReferencesTemplate($gt, $templateNames)) { + return true; + } + } + + return false; + } + + if ($node instanceof ArrayTypeNode || $node instanceof NullableTypeNode) { + return self::typeReferencesTemplate($node->type, $templateNames); + } + + if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) { + foreach ($node->types as $t) { + if (self::typeReferencesTemplate($t, $templateNames)) { + return true; + } + } + + return false; + } + + if ($node instanceof ArrayShapeNode) { + foreach ($node->items as $item) { + if (self::typeReferencesTemplate($item->valueType, $templateNames)) { + return true; + } + } + + if ($node->unsealedType !== null) { + if ($node->unsealedType->keyType !== null && self::typeReferencesTemplate($node->unsealedType->keyType, $templateNames)) { + return true; + } + + return self::typeReferencesTemplate($node->unsealedType->valueType, $templateNames); + } + + return false; + } + + if ($node instanceof ObjectShapeNode) { + foreach ($node->items as $item) { + if (self::typeReferencesTemplate($item->valueType, $templateNames)) { + return true; + } + } + + return false; + } + + if ($node instanceof CallableTypeNode) { + foreach ($node->parameters as $p) { + if (self::typeReferencesTemplate($p->type, $templateNames)) { + return true; + } + } + + return self::typeReferencesTemplate($node->returnType, $templateNames); + } + + return false; + } + /** * Parses PHPDoc contracts for a function or class method. * - * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool} + * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool, paramsUseGenerics: bool, returnUsesGenerics: bool} */ public static function parse(string $function): array { @@ -178,6 +261,8 @@ public static function parse(string $function): array 'aliases' => $aliases, 'hasParamContract' => false, 'hasReturnContract' => false, + 'paramsUseGenerics' => false, + 'returnUsesGenerics' => false, ]; } } else { @@ -189,6 +274,8 @@ public static function parse(string $function): array 'aliases' => [], 'hasParamContract' => false, 'hasReturnContract' => false, + 'paramsUseGenerics' => false, + 'returnUsesGenerics' => false, ]; } } else { @@ -204,6 +291,8 @@ public static function parse(string $function): array 'aliases' => [], 'hasParamContract' => false, 'hasReturnContract' => false, + 'paramsUseGenerics' => false, + 'returnUsesGenerics' => false, ]; } @@ -548,7 +637,7 @@ public static function parseClassAliases(string $className): array /** * Orchestrates parsing for class methods across the inheritance hierarchy. * - * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool} + * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool, paramsUseGenerics: bool, returnUsesGenerics: bool, returnUsesMethodTemplates: bool, returnIsThis: bool, returnIsDynamic: bool} */ private static function parseMethod(\ReflectionMethod $ref): array { @@ -565,6 +654,37 @@ private static function parseMethod(\ReflectionMethod $ref): array self::applyConstructorPromotionFallback($ref, $types, $classTemplates, $aliases); } + $allTemplates = [...$classTemplates, ...$methodTemplates]; + $paramsUseGenerics = false; + if (\count($allTemplates) > 0) { + foreach ($types as $tNode) { + if (self::typeReferencesTemplate($tNode, $allTemplates)) { + $paramsUseGenerics = true; + + break; + } + } + } + + $returnUsesMethodTemplates = false; + if ($returnType !== null && \count($methodTemplates) > 0) { + $returnUsesMethodTemplates = self::typeReferencesTemplate($returnType, $methodTemplates); + } + + $returnUsesGenerics = $returnUsesMethodTemplates; + if (! $returnUsesGenerics && $returnType !== null && \count($classTemplates) > 0) { + $returnUsesGenerics = self::typeReferencesTemplate($returnType, $classTemplates); + } + + $returnIsThis = false; + $returnIsDynamic = false; + if ($returnType !== null) { + $retStr = (string) $returnType; + $returnIsThis = ($returnType instanceof \PHPStan\PhpDocParser\Ast\Type\ThisTypeNode) + || ($returnType instanceof IdentifierTypeNode && strtolower($returnType->name) === '$this'); + $returnIsDynamic = $returnIsThis || str_contains($retStr, 'static') || str_contains($retStr, '$this'); + } + return [ 'types' => $types, 'templates' => $methodTemplates, @@ -573,13 +693,18 @@ private static function parseMethod(\ReflectionMethod $ref): array 'aliases' => $aliases, 'hasParamContract' => \count($types) > 0, 'hasReturnContract' => $returnType !== null, + 'paramsUseGenerics' => $paramsUseGenerics, + 'returnUsesGenerics' => $returnUsesGenerics, + 'returnUsesMethodTemplates' => $returnUsesMethodTemplates, + 'returnIsThis' => $returnIsThis, + 'returnIsDynamic' => $returnIsDynamic, ]; } /** * Orchestrates parsing for standalone global or namespaced functions. * - * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool} + * @return array{types: array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool, paramsUseGenerics: bool, returnUsesGenerics: bool, returnUsesMethodTemplates: bool, returnIsThis: bool, returnIsDynamic: bool} */ private static function parseFunction(\ReflectionFunction $ref): array { @@ -601,6 +726,11 @@ private static function parseFunction(\ReflectionFunction $ref): array 'aliases' => [], 'hasParamContract' => false, 'hasReturnContract' => false, + 'paramsUseGenerics' => false, + 'returnUsesGenerics' => false, + 'returnUsesMethodTemplates' => false, + 'returnIsThis' => false, + 'returnIsDynamic' => false, ]; } @@ -658,6 +788,31 @@ private static function parseFunction(\ReflectionFunction $ref): array } } + $paramsUseGenerics = false; + if (\count($templates) > 0) { + foreach ($types as $tNode) { + if (self::typeReferencesTemplate($tNode, $templates)) { + $paramsUseGenerics = true; + + break; + } + } + } + + $returnUsesMethodTemplates = false; + if ($returnType !== null && \count($templates) > 0) { + $returnUsesMethodTemplates = self::typeReferencesTemplate($returnType, $templates); + } + + $returnIsThis = false; + $returnIsDynamic = false; + if ($returnType !== null) { + $retStr = (string) $returnType; + $returnIsThis = ($returnType instanceof \PHPStan\PhpDocParser\Ast\Type\ThisTypeNode) + || ($returnType instanceof IdentifierTypeNode && strtolower($returnType->name) === '$this'); + $returnIsDynamic = $returnIsThis || str_contains($retStr, 'static') || str_contains($retStr, '$this'); + } + return [ 'types' => $types, 'templates' => $templates, @@ -666,6 +821,11 @@ private static function parseFunction(\ReflectionFunction $ref): array 'aliases' => $aliases, 'hasParamContract' => \count($types) > 0, 'hasReturnContract' => $returnType !== null, + 'paramsUseGenerics' => $paramsUseGenerics, + 'returnUsesGenerics' => $returnUsesMethodTemplates, + 'returnUsesMethodTemplates' => $returnUsesMethodTemplates, + 'returnIsThis' => $returnIsThis, + 'returnIsDynamic' => $returnIsDynamic, ]; } @@ -889,7 +1049,6 @@ private static function applyConstructorPromotionFallback( if (! isset($types[$paramName]) && $declaringClass->hasProperty($paramName)) { $propertyRef = $declaringClass->getProperty($paramName); - // For non-promoted parameters, ensure constructor param and property native types are compatible if (! self::areConstructorParamAndPropertyCompatible($p, $propertyRef)) { continue; } @@ -990,12 +1149,10 @@ private static function wrapVariadicParameterType( } } - // If native parameter is array ...$items, each argument is an array, so wrap in ArrayTypeNode if ($isNativeArrayOrIterable) { return new ArrayTypeNode($type); } - // If DocBlock type is not already an ArrayTypeNode or list/array GenericTypeNode if ( ! ($type instanceof ArrayTypeNode) && ! ($type instanceof GenericTypeNode && \in_array(strtolower($type->type->name), ['array', 'list', 'iterable', 'traversable', 'non-empty-array', 'non-empty-list'], true)) @@ -1053,7 +1210,7 @@ private static function parameterExplicitlyAllowsNull(\ReflectionParameter $p): } /** - * Checks if a reflection function or method explicitly declares a nullable native return type (excluding mixed, void, and never). + * Checks if a reflection function or method explicitly declares a nullable native return type (excluding void, mixed, and never). */ private static function returnTypeExplicitlyAllowsNull(\ReflectionFunctionAbstract $ref): bool { diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index d832410..d4bfe4a 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -17,10 +17,10 @@ use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockExtractor; -use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\ClassNameValidator; +use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\FileFilter; use TypePHP\Internal\Util\StubManager; use WeakMap; @@ -72,6 +72,28 @@ final class TemplateManager */ public static ?object $pendingCloneSource = null; + /** + * Fast O(1) hashmap for recognized key template names. + * + * @var array + */ + private const KEY_TEMPLATE_NAMES = [ + 'tkey' => true, + 'key' => true, + 'k' => true, + ]; + + /** + * Fast O(1) hashmap for class context self-referencing keywords. + * + * @var array + */ + private const CONTEXT_SELF_REFERENCES = [ + 'self' => true, + 'static' => true, + '$this' => true, + ]; + /** * O(1) direct hash-table matrix for scalar subtype relationships. * @@ -304,6 +326,27 @@ final class TemplateManager 'scalar' => true, ]; + private static ?IdentifierTypeNode $intNode = null; + + private static ?IdentifierTypeNode $stringNode = null; + + private static ?IdentifierTypeNode $floatNode = null; + + private static ?IdentifierTypeNode $boolNode = null; + + private static ?IdentifierTypeNode $listNode = null; + + private static ?IdentifierTypeNode $arrayNode = null; + + private static ?IdentifierTypeNode $nullNode = null; + + private static ?IdentifierTypeNode $mixedNode = null; + + /** + * @var array + */ + public static array $classNodeCache = []; + /** * Resets all static generic template bindings and call stack frames. */ @@ -336,8 +379,7 @@ public static function normalizeGenericArguments(array $genericTypes, array $tem $firstBound = $firstTemplate->bound !== null ? strtolower((string) $firstTemplate->bound) : ''; $firstName = strtolower($firstTemplate->name); - $isKeyTemplate = $firstBound === 'array-key' - || \in_array($firstName, ['tkey', 'key', 'k'], true); + $isKeyTemplate = $firstBound === 'array-key' || isset(self::KEY_TEMPLATE_NAMES[$firstName]); if ($isKeyTemplate) { $defaultKeyNode = $firstTemplate->default ?? $firstTemplate->bound ?? new IdentifierTypeNode('array-key'); @@ -373,8 +415,11 @@ public static function pushCallFrame(string $function): void */ public static function popCallFrame(string $function): void { - if (self::hasCallFrame($function)) { + if (isset(self::$callStackBindings[$function])) { array_pop(self::$callStackBindings[$function]); + if (self::$callStackBindings[$function] === []) { + unset(self::$callStackBindings[$function]); + } } } @@ -400,35 +445,28 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr $bindings = []; if ($thisObj !== null) { - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); - } + self::ensureInstanceInherited($thisObj); if (isset(self::$instanceTemplateBindings[$thisObj])) { $bindings = self::$instanceTemplateBindings[$thisObj]; } } - if (self::hasCallFrame($function)) { - $topFrame = end(self::$callStackBindings[$function]); - if ($topFrame !== false) { - if ($thisObj !== null) { - $contract = DocblockParser::parse($function); - $methodTemplates = $contract['templates'] ?? []; - foreach ($topFrame as $tName => $tNode) { - if (isset($methodTemplates[$tName])) { - $bindings[$tName] = $tNode; - } - } - } else { - $bindings = [...$bindings, ...$topFrame]; - } - } + $topFrame = self::getTopCallFrame($function); + if ($topFrame !== null) { + $bindings = $bindings === [] + ? $topFrame + : [...$bindings, ...$topFrame]; } return $bindings; } + public static function hasInstanceBindings(object $instance): bool + { + return self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$instance]); + } + /** * Retrieves all bound template TypeNodes for a specific object instance. * @@ -436,9 +474,7 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr */ public static function getBoundTemplatesForInstance(object $instance): array { - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$instance])) { - self::resolveInheritedTemplates($instance, \get_class($instance)); - } + self::ensureInstanceInherited($instance); if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$instance])) { return self::$instanceTemplateBindings[$instance]; @@ -454,12 +490,11 @@ public static function getBoundTemplatesForInstance(object $instance): array */ public static function getTemplateVariances(object $instance): array { - $className = \get_class($instance); + $className = $instance::class; try { $stubDoc = StubManager::getClassDoc($className); - /** @var class-string $className */ - $ref = new \ReflectionClass($className); + $ref = new \ReflectionClass($instance); $classDoc = $stubDoc ?? $ref->getDocComment(); if ($classDoc !== false && $classDoc !== null) { @@ -479,17 +514,13 @@ public static function getTemplateVariances(object $instance): array */ public static function isBound(string $function, ?object $thisObj, string $templateName): bool { - if (self::hasCallFrame($function)) { - $topFrame = end(self::$callStackBindings[$function]); - if ($topFrame !== false && isset($topFrame[$templateName])) { - return true; - } + $topFrame = self::getTopCallFrame($function); + if ($topFrame !== null && isset($topFrame[$templateName])) { + return true; } if ($thisObj !== null) { - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); - } + self::ensureInstanceInherited($thisObj); return isset(self::$instanceTemplateBindings[$thisObj][$templateName]); } @@ -502,17 +533,13 @@ public static function isBound(string $function, ?object $thisObj, string $templ */ public static function getBoundType(string $function, ?object $thisObj, string $templateName): ?TypeNode { - if (self::hasCallFrame($function)) { - $topFrame = end(self::$callStackBindings[$function]); - if ($topFrame !== false && isset($topFrame[$templateName])) { - return $topFrame[$templateName]; - } + $topFrame = self::getTopCallFrame($function); + if ($topFrame !== null && isset($topFrame[$templateName])) { + return $topFrame[$templateName]; } if ($thisObj !== null) { - if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); - } + self::ensureInstanceInherited($thisObj); return self::$instanceTemplateBindings[$thisObj][$templateName] ?? null; } @@ -545,11 +572,11 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { $className = $typeNode->type->name; - if (\in_array(strtolower($className), ['self', 'static', '$this'], true)) { - $className = \get_class($instance); + if (isset(self::CONTEXT_SELF_REFERENCES[strtolower($className)])) { + $className = $instance::class; } - if (! is_a($instance, $className) || ! ClassNameValidator::isValid($className) || (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className))) { + if (! is_a($instance, $className) || ! ClassNameValidator::isValid($className) || ! self::isRealTypeSymbol($className)) { return null; } @@ -649,7 +676,7 @@ private static function bindSingleTemplateArgument( ): ?ErrorMessage { if ($expectedTypeNode instanceof IdentifierTypeNode) { $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($expectedTypeNode->name); - $isRealType = class_exists($expectedTypeNode->name) || interface_exists($expectedTypeNode->name) || enum_exists($expectedTypeNode->name) || trait_exists($expectedTypeNode->name); + $isRealType = self::isRealTypeSymbol($expectedTypeNode->name); if (! $isBuiltIn && ! $isRealType) { return null; @@ -666,18 +693,18 @@ private static function bindSingleTemplateArgument( } } - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); - } + self::$instanceTemplateBindings ??= new WeakMap(); $declaredVariance = $classVariances[$templateTag->name] ?? GenericTypeNode::VARIANCE_INVARIANT; - - // Return values are naturally in a covariant position under Liskov Substitution Principle $isReturnContext = str_contains($context, 'Return value'); + $strictInvariance = Config::isStrictReturnGenericInvarianceEnabled(); - $variance = ($usageVariance !== GenericTypeNode::VARIANCE_INVARIANT) - ? $usageVariance - : ($isReturnContext ? GenericTypeNode::VARIANCE_COVARIANT : $declaredVariance); + $variance = match (true) { + $usageVariance !== GenericTypeNode::VARIANCE_INVARIANT => $usageVariance, + $declaredVariance === GenericTypeNode::VARIANCE_CONTRAVARIANT => GenericTypeNode::VARIANCE_CONTRAVARIANT, + $isReturnContext && ! $strictInvariance => GenericTypeNode::VARIANCE_COVARIANT, + default => $declaredVariance, + }; $templateName = $templateTag->name; $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; @@ -695,7 +722,7 @@ private static function bindSingleTemplateArgument( ); if (self::checkVariance($expectedTypeNode, $existingTypeNode, GenericTypeNode::VARIANCE_COVARIANT)) { - if ($isDefaultOrBound || $isReturnContext) { + if ($isDefaultOrBound || ($isReturnContext && ! $strictInvariance)) { $bindings = self::$instanceTemplateBindings[$instance] ?? []; $bindings[$templateName] = $expectedTypeNode; self::$instanceTemplateBindings[$instance] = $bindings; @@ -741,20 +768,14 @@ private static function bindSingleTemplateArgument( */ public static function resolveInheritedTemplates(object $instance, string $targetClassName): void { - $actualClassName = \get_class($instance); + $actualClassName = $instance::class; + self::$instanceTemplateBindings ??= new WeakMap(); if (isset(self::$classInheritedBindingsCache[$actualClassName])) { - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); - } - /** @var array $cachedBindings */ $cachedBindings = self::$classInheritedBindingsCache[$actualClassName]; - if (\count($cachedBindings) > 0) { - /** @var array $existing */ - $existing = self::$instanceTemplateBindings[$instance] ?? []; - self::$instanceTemplateBindings[$instance] = [...$cachedBindings, ...$existing]; - } + $existing = self::$instanceTemplateBindings[$instance] ?? []; + self::$instanceTemplateBindings[$instance] = [...$cachedBindings, ...$existing]; return; } @@ -762,14 +783,8 @@ public static function resolveInheritedTemplates(object $instance, string $targe $resolvedClassBindings = self::computeClassInheritedBindings($actualClassName); self::$classInheritedBindingsCache[$actualClassName] = $resolvedClassBindings; - if (\count($resolvedClassBindings) > 0) { - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); - } - /** @var array $existing */ - $existing = self::$instanceTemplateBindings[$instance] ?? []; - self::$instanceTemplateBindings[$instance] = [...$resolvedClassBindings, ...$existing]; - } + $existing = self::$instanceTemplateBindings[$instance] ?? []; + self::$instanceTemplateBindings[$instance] = [...$resolvedClassBindings, ...$existing]; } /** @@ -846,7 +861,7 @@ private static function collectInheritedGenericTagBindings( return; } - if (! class_exists($parentName) && ! interface_exists($parentName) && ! trait_exists($parentName)) { + if (! self::isRealTypeSymbol($parentName)) { return; } @@ -871,7 +886,7 @@ private static function collectInheritedGenericTagBindings( if ($resolved instanceof IdentifierTypeNode) { $isBuiltIn = SpecialTypeResolver::isBuiltInTypeKeyword($resolved->name); - $isRealType = class_exists($resolved->name) || interface_exists($resolved->name) || enum_exists($resolved->name) || trait_exists($resolved->name); + $isRealType = self::isRealTypeSymbol($resolved->name); if (! $isBuiltIn && ! $isRealType) { continue; @@ -1068,7 +1083,7 @@ private static function checkExpectedIntersectionVariance(TypeNode $existing, In { if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { foreach ($expected->types as $intersectionMember) { - if (self::checkVariance($existing, $intersectionMember, $variance)) { + if (! self::checkVariance($existing, $intersectionMember, $variance)) { return true; } } @@ -1142,8 +1157,8 @@ private static function isSubclass(string $sub, string $super): bool if ( ClassNameValidator::isValid($baseSub) && ClassNameValidator::isValid($baseSuper) - && (class_exists($baseSub) || interface_exists($baseSub) || trait_exists($baseSub) || enum_exists($baseSub)) - && (class_exists($baseSuper) || interface_exists($baseSuper) || trait_exists($baseSuper) || enum_exists($baseSuper)) + && self::isRealTypeSymbol($baseSub) + && self::isRealTypeSymbol($baseSuper) ) { $result = is_a($baseSub, $baseSuper, true); } @@ -1177,42 +1192,45 @@ public static function bindInstance(object $instance, string $typeString, string } /** - * Infers a TypeNode AST representation from a raw PHP value. + * Infers a TypeNode AST representation from a raw PHP value using cached singleton nodes. */ public static function inferTypeFromValue(mixed $value): TypeNode { if (\is_int($value)) { - return new IdentifierTypeNode('int'); + return self::$intNode ??= new IdentifierTypeNode('int'); } if (\is_string($value)) { - return new IdentifierTypeNode('string'); + return self::$stringNode ??= new IdentifierTypeNode('string'); } if (\is_float($value)) { - return new IdentifierTypeNode('float'); + return self::$floatNode ??= new IdentifierTypeNode('float'); } if (\is_bool($value)) { - return new IdentifierTypeNode('bool'); + return self::$boolNode ??= new IdentifierTypeNode('bool'); } if (\is_array($value)) { - return new IdentifierTypeNode(array_is_list($value) ? 'list' : 'array'); + return array_is_list($value) + ? (self::$listNode ??= new IdentifierTypeNode('list')) + : (self::$arrayNode ??= new IdentifierTypeNode('array')); } if (\is_object($value)) { - $className = \get_class($value); + $className = $value::class; + if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value]) && \count(self::$instanceTemplateBindings[$value]) > 0) { $genericTypes = array_values(self::$instanceTemplateBindings[$value]); - return new GenericTypeNode(new IdentifierTypeNode($className), $genericTypes); + return new GenericTypeNode(self::$classNodeCache[$className] ??= new IdentifierTypeNode($className), $genericTypes); } - return new IdentifierTypeNode($className); + return self::$classNodeCache[$className] ??= new IdentifierTypeNode($className); } if ($value === null) { - return new IdentifierTypeNode('null'); + return self::$nullNode ??= new IdentifierTypeNode('null'); } - return new IdentifierTypeNode('mixed'); + return self::$mixedNode ??= new IdentifierTypeNode('mixed'); } /** @@ -1220,7 +1238,33 @@ public static function inferTypeFromValue(mixed $value): TypeNode */ private static function hasCallFrame(string $function): bool { - return isset(self::$callStackBindings[$function]) && \count(self::$callStackBindings[$function]) > 0; + return isset(self::$callStackBindings[$function]); + } + + /** + * @return array|null + */ + private static function getTopCallFrame(string $function): ?array + { + if (! isset(self::$callStackBindings[$function]) || self::$callStackBindings[$function] === []) { + return null; + } + + $top = end(self::$callStackBindings[$function]); + + return $top !== false ? $top : null; + } + + private static function ensureInstanceInherited(object $instance): void + { + if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$instance])) { + self::resolveInheritedTemplates($instance, $instance::class); + } + } + + private static function isRealTypeSymbol(string $name): bool + { + return class_exists($name) || interface_exists($name) || enum_exists($name) || trait_exists($name); } /** diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index b411501..745938a 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -239,7 +239,7 @@ public static function transformSource(string $source, string $filePath = ''): s } /** - * Safely neutralizes trailing single-line comments (// or #) preceding an injected check + * Safely neutralises trailing single-line comments (// or #) preceding an injected check * into block comments without corrupting string literals containing '//' or '#'. */ private static function neutralizeTrailingLineComments(string $code): string diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index ad031b6..4def22d 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -49,13 +49,27 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t /** * Evaluates inline variable validation dynamically based on configuration. */ - public static function checkVariable(mixed $value, string $typeString, string $varName, string $file): mixed - { + public static function checkVariable( + mixed $value, + string $typeString, + string $varName, + string $file, + ?string $caller = null, + mixed $thisOrClass = null + ): mixed { if (! Config::isEnabled()) { return $value; } - return InlineChecker::checkVariable($value, $typeString, $varName, $file, self::getRegistry()); + return InlineChecker::checkVariable( + $value, + $typeString, + $varName, + $file, + self::getRegistry(), + $caller, + $thisOrClass + ); } /** @@ -63,6 +77,11 @@ public static function checkVariable(mixed $value, string $typeString, string $v */ public static function checkProperty(mixed $value, mixed $objectOrClass, string $propName, string $file): mixed { + $className = \is_object($objectOrClass) ? $objectOrClass::class : (\is_string($objectOrClass) ? $objectOrClass : ''); + if ($className !== '' && isset(InlineChecker::$nullPropertyCache[$className . '::$' . $propName])) { + return $value; + } + if (! Config::isEnabled()) { return $value; } @@ -71,12 +90,25 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string } /** - * Initializes generic call frames and returns a ScopeCleaner that pops the call frame on destruction. + * @var array + */ + private static array $hasMethodTemplatesCache = []; + + /** + * Initialises generic call frames and returns a ScopeCleaner that pops the call frame on destruction. * * @param array $vars */ public static function setupScope(string $function, array $vars, object|string|null $thisOrClass = null): ErrorMessage|ScopeCleaner|null { + if (! Config::isParamsEnabled()) { + return null; + } + + if (isset(ParamChecker::$noParamContractCache[$function]) && ! (self::$hasMethodTemplatesCache[$function] ?? false)) { + return null; + } + if (! Config::isEnabled()) { return null; } @@ -84,20 +116,27 @@ public static function setupScope(string $function, array $vars, object|string|n $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry(), $effectiveFunction); + if (ParamChecker::areAllParamsUnconstrained($effectiveFunction)) { + return null; + } - $contract = DocblockParser::parse($effectiveFunction); - $methodTemplates = $contract['templates'] ?? []; - $hasMethodTemplates = \count($methodTemplates) > 0; + $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry(), $effectiveFunction); if ($err !== null) { - if ($hasMethodTemplates) { - TemplateManager::popCallFrame($effectiveFunction); - } + TemplateManager::popCallFrame($effectiveFunction); return $err; } + $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] ?? null; + if ($hasMethodTemplates === null) { + $contract = DocblockParser::parse($effectiveFunction); + $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] = ( + $contract['returnUsesMethodTemplates'] ?? false + ); + self::$hasMethodTemplatesCache[$function] = $hasMethodTemplates; + } + return $hasMethodTemplates ? new ScopeCleaner($effectiveFunction) : null; } @@ -118,14 +157,28 @@ public static function checkParams(string $function, array $vars, object|string| /** * Validates a function or method's return value against its declared contract and returns value or ErrorMessage. * - * @param array $vars + * @param array|null $vars */ - public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass = null, array $vars = []): mixed + public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass = null, ?array $vars = []): mixed { + if (isset(ReturnChecker::$noReturnContractCache[$function])) { + return $value; + } + if (! Config::isEnabled()) { return $value; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + // Fast-path: if return type is unconstrained, skip validation. + if (ReturnChecker::isReturnUnconstrained($effectiveFunction)) { + return $value; + } + + $vars ??= []; + return ReturnChecker::checkReturn($function, $value, $thisOrClass, $vars, self::getRegistry(), [self::class, 'wrapIterable']); } diff --git a/src/Internal/Util/ClassNameValidator.php b/src/Internal/Util/ClassNameValidator.php index b0717e4..8916fdc 100644 --- a/src/Internal/Util/ClassNameValidator.php +++ b/src/Internal/Util/ClassNameValidator.php @@ -9,11 +9,25 @@ */ final class ClassNameValidator { + /** + * @var array + */ + private static array $validSyntaxCache = []; + + /** + * @var array + */ + private static array $validClassStringCache = []; + + public static function reset(): void + { + self::$validSyntaxCache = []; + self::$validClassStringCache = []; + } + /** * Validates whether a given value is a syntactically valid PHP class, interface, trait, or enum identifier, * or a valid anonymous class name registered in memory. - * Handles fully-qualified names with leading backslashes. - * Returns false for non-strings, empty strings, complex PHPDoc strings like "Producer", "array{id: int}", or unions. */ public static function isValid(mixed $name): bool { @@ -21,16 +35,22 @@ public static function isValid(mixed $name): bool return false; } + if (isset(self::$validSyntaxCache[$name])) { + return self::$validSyntaxCache[$name]; + } + if (str_contains($name, '@anonymous')) { - return class_exists($name, false); + return self::$validSyntaxCache[$name] = class_exists($name, false); } $trimmed = ltrim($name, '\\'); if ($trimmed === '') { - return false; + return self::$validSyntaxCache[$name] = false; } - return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(?:\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*$/', $trimmed) === 1; + $valid = preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(?:\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*$/', $trimmed) === 1; + + return self::$validSyntaxCache[$name] = $valid; } /** @@ -40,16 +60,28 @@ public static function isValid(mixed $name): bool */ public static function isValidClassString(mixed $name): bool { - if (! \is_string($name) || ! self::isValid($name)) { + if (! \is_string($name) || $name === '') { return false; } + if (isset(self::$validClassStringCache[$name])) { + return self::$validClassStringCache[$name]; + } + + if (! self::isValid($name)) { + return self::$validClassStringCache[$name] = false; + } + + if (class_exists($name, false) || interface_exists($name, false) || trait_exists($name, false) || enum_exists($name, false)) { + return self::$validClassStringCache[$name] = true; + } + if (class_exists($name) || interface_exists($name) || trait_exists($name) || enum_exists($name)) { - return true; + return self::$validClassStringCache[$name] = true; } $trimmed = ltrim($name, '\\'); - return str_contains($trimmed, '\\'); + return self::$validClassStringCache[$name] = str_contains($trimmed, '\\'); } } diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index bf9baa4..d2ed4b3 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -42,6 +42,8 @@ final class Config private static bool $returns = true; + private static bool $strictReturnGenericInvariance = true; + private static bool $magicProperties = true; private static bool $magicMethods = true; @@ -79,6 +81,15 @@ public static function isReturnsEnabled(): bool return self::$returns; } + public static function isStrictReturnGenericInvarianceEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$strictReturnGenericInvariance; + } + public static function isMagicPropertiesEnabled(): bool { if (self::$cachedConfig === null) { @@ -133,14 +144,6 @@ public static function getArrayValidationStrategy(): string return self::$arrayValidation; } - /** - * Locates the project root directory by searching upwards for vendor/autoload.php, composer.json, or typephp.php. - * Caches the result in memory so the search happens exactly once. - */ - /** - * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. - * Caches the result in memory so the search happens exactly once. - */ /** * Locates the project root directory by searching upwards for vendor/autoload.php or composer.json. * Caches the result in memory so the search happens exactly once. @@ -211,6 +214,7 @@ public static function get(): array 'enabled' => true, 'params' => true, 'returns' => true, + 'strict_return_generic_invariance' => true, 'magic_properties' => true, 'magic_methods' => true, 'respect_ignore_tags' => true, @@ -348,6 +352,7 @@ public static function reset(): void self::$enabled = true; self::$params = true; self::$returns = true; + self::$strictReturnGenericInvariance = true; self::$magicProperties = true; self::$magicMethods = true; self::$respectIgnoreTags = true; @@ -377,6 +382,7 @@ private static function syncFlags(array $config): void self::$enabled = (bool) ($config['enabled'] ?? true); self::$params = (bool) ($config['params'] ?? true); self::$returns = (bool) ($config['returns'] ?? true); + self::$strictReturnGenericInvariance = (bool) ($config['strict_return_generic_invariance'] ?? true); self::$magicProperties = (bool) ($config['magic_properties'] ?? true); self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index ada7176..cbdffe7 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -405,31 +405,25 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte } $valueTypeNode = $node->genericTypes[0] ?? null; - if ($valueTypeNode !== null && $count > 0) { - $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - - if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { - $sampleIndices = [0, $count - 1]; - $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { - $sampleIndices[] = mt_rand(1, $count - 2); - } + if ($valueTypeNode === null || $count === 0) { + return null; + } - foreach ($sampleIndices as $k) { - $v = $value[$k]; - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valueTypeNode, '') - : $registry->validate($v, $valueTypeNode, ''); + if ($valueTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($valueTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { + return null; + } - if ($err !== null) { - return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); - } - } + $isComplexObjectGeneric = ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); - return null; + if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { + $sampleIndices = [0, $count - 1]; + $samplesToTake = min(3, $count - 2); + for ($i = 0; $i < $samplesToTake; $i++) { + $sampleIndices[] = mt_rand(1, $count - 2); } - foreach ($value as $k => $v) { + foreach ($sampleIndices as $k) { + $v = $value[$k]; $err = $isComplexObjectGeneric ? $this->validateObjectGeneric($v, $valueTypeNode, '') : $registry->validate($v, $valueTypeNode, ''); @@ -438,6 +432,18 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); } } + + return null; + } + + foreach ($value as $k => $v) { + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valueTypeNode, '') + : $registry->validate($v, $valueTypeNode, ''); + + if ($err !== null) { + return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); + } } return null; @@ -471,6 +477,11 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont $typesCount = \count($node->genericTypes); if ($typesCount === 1) { $valTypeNode = $node->genericTypes[0]; + + if ($valTypeNode instanceof IdentifierTypeNode && \in_array(strtolower($valTypeNode->name), ['mixed', 't', 'tvalue', 'v', 'value', 'telement'], true)) { + return null; + } + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { $keys = array_keys($value); @@ -506,6 +517,14 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont } elseif ($typesCount >= 2) { $keyTypeNode = $node->genericTypes[0]; $valTypeNode = $node->genericTypes[1]; + + $keyIsArrayKey = ($keyTypeNode instanceof IdentifierTypeNode) && \in_array(strtolower($keyTypeNode->name), ['array-key', 'mixed', 'tkey', 'key', 'k'], true); + $valIsMixed = ($valTypeNode instanceof IdentifierTypeNode) && \in_array(strtolower($valTypeNode->name), ['mixed', 'tvalue', 'v', 'value', 't'], true); + + if ($keyIsArrayKey && $valIsMixed) { + return null; + } + $isComplexObjectGeneric = ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)); if ($count > Config::HYBRID_SAMPLE_THRESHOLD && Config::isArrayValidationHybrid()) { @@ -517,18 +536,22 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont } foreach ($sampleKeys as $k) { - $err = $registry->validate($k, $keyTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . ' key' . $err->getMessage()); + if (! $keyIsArrayKey) { + $err = $registry->validate($k, $keyTypeNode, ''); + if ($err !== null) { + return ErrorFactory::createError($context . ' key' . $err->getMessage()); + } } - $v = $value[$k]; - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '') - : $registry->validate($v, $valTypeNode, ''); + if (! $valIsMixed) { + $v = $value[$k]; + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '') + : $registry->validate($v, $valTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + if ($err !== null) { + return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + } } } @@ -536,17 +559,21 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont } foreach ($value as $k => $v) { - $err = $registry->validate($k, $keyTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . ' key' . $err->getMessage()); + if (! $keyIsArrayKey) { + $err = $registry->validate($k, $keyTypeNode, ''); + if ($err !== null) { + return ErrorFactory::createError($context . ' key' . $err->getMessage()); + } } - $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '') - : $registry->validate($v, $valTypeNode, ''); + if (! $valIsMixed) { + $err = $isComplexObjectGeneric + ? $this->validateObjectGeneric($v, $valTypeNode, '') + : $registry->validate($v, $valTypeNode, ''); - if ($err !== null) { - return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + if ($err !== null) { + return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); + } } } } diff --git a/src/Internal/Validator/TypeValidatorRegistry.php b/src/Internal/Validator/TypeValidatorRegistry.php index 8ca9a8a..a3efc2c 100644 --- a/src/Internal/Validator/TypeValidatorRegistry.php +++ b/src/Internal/Validator/TypeValidatorRegistry.php @@ -40,18 +40,14 @@ final class TypeValidatorRegistry private ConstValidator $constValidator; /** - * WeakMap memoizing previously validated object instances against TypeNode signatures. + * Static map for fast validator resolution. * - * @var \WeakMap>|null + * @var array */ - private static ?\WeakMap $validatedObjectCache = null; + private array $validatorMap; - /** - * Resets the validated object cache. Useful for test isolation. - */ public static function reset(): void { - self::$validatedObjectCache = null; } public function __construct() @@ -65,6 +61,18 @@ public function __construct() $this->arrayShapeValidator = new ArrayShapeValidator(); $this->objectShapeValidator = new ObjectShapeValidator(); $this->constValidator = new ConstValidator(); + + $this->validatorMap = [ + IdentifierTypeNode::class => $this->identifierValidator, + GenericTypeNode::class => $this->genericValidator, + UnionTypeNode::class => $this->unionValidator, + NullableTypeNode::class => $this->nullableValidator, + ArrayTypeNode::class => $this->arrayValidator, + ArrayShapeNode::class => $this->arrayShapeValidator, + ObjectShapeNode::class => $this->objectShapeValidator, + IntersectionTypeNode::class => $this->intersectionValidator, + ConstTypeNode::class => $this->constValidator, + ]; } /** @@ -72,38 +80,11 @@ public function __construct() */ public function validate(mixed $value, TypeNode $node, string $context = ''): ?ErrorMessage { - $isObj = \is_object($value); - $nodeKey = null; - - // Object Validation Memoization Optimization (O(1) lookup for repeated object checks) - if ($isObj) { - self::$validatedObjectCache ??= new \WeakMap(); - $nodeKey = ($node instanceof IdentifierTypeNode) ? $node->name : (string) $node; - - if (isset(self::$validatedObjectCache[$value][$nodeKey])) { - return null; - } - } - - $err = match ($node::class) { - IdentifierTypeNode::class => $this->identifierValidator->validate($value, $node, $context, $this), - GenericTypeNode::class => $this->genericValidator->validate($value, $node, $context, $this), - UnionTypeNode::class => $this->unionValidator->validate($value, $node, $context, $this), - NullableTypeNode::class => $this->nullableValidator->validate($value, $node, $context, $this), - ArrayTypeNode::class => $this->arrayValidator->validate($value, $node, $context, $this), - ArrayShapeNode::class => $this->arrayShapeValidator->validate($value, $node, $context, $this), - ObjectShapeNode::class => $this->objectShapeValidator->validate($value, $node, $context, $this), - IntersectionTypeNode::class => $this->intersectionValidator->validate($value, $node, $context, $this), - ConstTypeNode::class => $this->constValidator->validate($value, $node, $context, $this), - default => null, - }; - - if ($err === null && $isObj && $nodeKey !== null) { - $cache = self::$validatedObjectCache[$value] ?? []; - $cache[$nodeKey] = true; - self::$validatedObjectCache[$value] = $cache; + $validator = $this->validatorMap[$node::class] ?? null; + if ($validator === null) { + return null; } - return $err; + return $validator->validate($value, $node, $context, $this); } } diff --git a/src/Internal/Validator/UnionValidator.php b/src/Internal/Validator/UnionValidator.php index 0bef7d5..9d6ed91 100644 --- a/src/Internal/Validator/UnionValidator.php +++ b/src/Internal/Validator/UnionValidator.php @@ -4,11 +4,13 @@ namespace TypePHP\Internal\Validator; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; +use TypePHP\Internal\Util\ClassNameValidator; /** * @internal Class for validating union types like int | string. @@ -20,6 +22,30 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali /** @var UnionTypeNode $unionNode */ $unionNode = $node; + foreach ($unionNode->types as $type) { + if ($type instanceof IdentifierTypeNode) { + $name = strtolower($type->name); + if ($name === 'object' && \is_object($value)) { + return null; + } + if (($name === 'int' || $name === 'integer') && \is_int($value)) { + return null; + } + if ($name === 'string' && \is_string($value)) { + return null; + } + if (($name === 'bool' || $name === 'boolean') && \is_bool($value)) { + return null; + } + if ($name === 'null' && $value === null) { + return null; + } + if ($name === 'class-string' && \is_string($value) && ClassNameValidator::isValidClassString($value)) { + return null; + } + } + } + $deepErrors = []; foreach ($unionNode->types as $type) { diff --git a/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php b/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php index 6ccb386..2e4b799 100644 --- a/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php +++ b/tests/TypeChecking/Boundaries/LateStaticBindingReturnTest.php @@ -91,7 +91,7 @@ test('throws TypeError when generic static factory returns instance violating generic template T', function () { expect(fn () => UserGenericFactory::ofBadItem(new Dog())) - ->toThrow(TypeError::class, 'UserGenericFactory, but TypePHP\Tests\Fixtures\Services\UserGenericFactory was returned') + ->toThrow(TypeError::class, 'UserGenericFactory, but TypePHP\Tests\Fixtures\Services\UserGenericFactory was returned') ; }); diff --git a/tests/TypeChecking/Generics/GenericReturnCovarianceTest.php b/tests/TypeChecking/Generics/GenericReturnCovarianceTest.php index 0883b41..fc21491 100644 --- a/tests/TypeChecking/Generics/GenericReturnCovarianceTest.php +++ b/tests/TypeChecking/Generics/GenericReturnCovarianceTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use TypePHP\Exception\TypeError; +use TypePHP\Internal\Util\Config; use TypePHP\Tests\Fixtures\Domain\Animal; use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Dog; @@ -17,24 +19,74 @@ function testReturnGenericCollection(GenericCollection $collection): GenericColl return $collection; } -describe('Generic Return Covariance (Liskov Subtyping on Return Types)', function () { - test('accepts GenericCollection holding Dog subclass when return contract specifies Animal', function () { - /** @var GenericCollection $dogCollection */ - $dogCollection = new GenericCollection(); - $dogCollection->add(new Dog()); +/** + * Method declaring return type of GenericCollection + * + * @return GenericCollection + */ +function testReturnUseSiteCovariantCollection(GenericCollection $collection): GenericCollection +{ + return $collection; +} + +describe('Generic Return Invariance & Covariance', function () { + afterEach(function () { + Config::reset(); + }); - $result = testReturnGenericCollection($dogCollection); + describe('Strict Mode (strict_return_generic_invariance => true [Default])', function () { + test('rejects GenericCollection returned where GenericCollection is expected (PHPStan Invariance Parity)', function () { + /** @var GenericCollection $dogCollection */ + $dogCollection = new GenericCollection(); + $dogCollection->add(new Dog()); - expect($result)->toBe($dogCollection); + expect(fn () => testReturnGenericCollection($dogCollection)) + ->toThrow(TypeError::class, 'expects TypePHP\Tests\Fixtures\Generics\GenericCollection, but TypePHP\Tests\Fixtures\Generics\GenericCollection was returned') + ; + }); + + test('accepts GenericCollection when return contract specifies use-site covariance ()', function () { + /** @var GenericCollection $dogCollection */ + $dogCollection = new GenericCollection(); + $dogCollection->add(new Dog()); + + $result = testReturnUseSiteCovariantCollection($dogCollection); + + expect($result)->toBe($dogCollection); + }); }); - test('throws TypeError when GenericCollection returned holds an unrelated type', function () { - /** @var GenericCollection $carCollection */ - $carCollection = new GenericCollection(); - $carCollection->add(new Car()); + describe('Pragmatic Mode (strict_return_generic_invariance => false [Framework Compatibility])', function () { + test('accepts GenericCollection holding Dog subclass when strict return invariance is disabled', function () { + try { + Config::set(['strict_return_generic_invariance' => false]); + + /** @var GenericCollection $dogCollection */ + $dogCollection = new GenericCollection(); + $dogCollection->add(new Dog()); + + $result = testReturnGenericCollection($dogCollection); + + expect($result)->toBe($dogCollection); + } finally { + Config::reset(); + } + }); + + test('throws TypeError when GenericCollection returned holds an unrelated type even in pragmatic mode', function () { + try { + Config::set(['strict_return_generic_invariance' => false]); + + /** @var GenericCollection $carCollection */ + $carCollection = new GenericCollection(); + $carCollection->add(new Car()); - expect(fn () => testReturnGenericCollection($carCollection)) - ->toThrow(TypeError::class) - ; + expect(fn () => testReturnGenericCollection($carCollection)) + ->toThrow(TypeError::class) + ; + } finally { + Config::reset(); + } + }); }); }); diff --git a/tests/TypeChecking/Generics/StrictReturnGenericInvarianceTest.php b/tests/TypeChecking/Generics/StrictReturnGenericInvarianceTest.php new file mode 100644 index 0000000..74cfdaa --- /dev/null +++ b/tests/TypeChecking/Generics/StrictReturnGenericInvarianceTest.php @@ -0,0 +1,120 @@ + + */ + public array $items = []; + + /** + * @param T $item + */ + public function add(mixed $item): void + { + $this->items[] = $item; + } +} + +/** + * Covariant collection (@template-covariant T) + * + * @template-covariant T + */ +class CovariantTestBox +{ + /** + * @param T $item + */ + public function __construct(public mixed $item) + { + } +} + +/** + * Function returning InvariantTestBox + * + * @return InvariantTestBox + */ +function produceAnimalBox(): InvariantTestBox +{ + /** @var InvariantTestBox $box */ + $box = new InvariantTestBox(); + $box->add(new Dog()); + + return $box; +} + +/** + * Function returning CovariantTestBox + * + * @return CovariantTestBox + */ +function produceCovariantAnimalBox(): CovariantTestBox +{ + return new CovariantTestBox(new Dog()); +} + +/** + * Function returning InvariantTestBox using use-site variance + * + * @return InvariantTestBox + */ +function produceUseSiteCovariantAnimalBox(): InvariantTestBox +{ + /** @var InvariantTestBox $box */ + $box = new InvariantTestBox(); + $box->add(new Dog()); + + return $box; +} + +describe('Strict Return Generic Invariance Configuration', function () { + afterEach(function () { + Config::reset(); + }); + + test('default strict mode rejects returning InvariantTestBox for InvariantTestBox (PHPStan Parity)', function () { + expect(fn () => produceAnimalBox()) + ->toThrow(TypeError::class, 'expects InvariantTestBox, but InvariantTestBox was returned') + ; + }); + + test('default strict mode allows returning CovariantTestBox when class declares @template-covariant T', function () { + $result = produceCovariantAnimalBox(); + + expect($result)->toBeInstanceOf(CovariantTestBox::class) + ->and($result->item)->toBeInstanceOf(Dog::class) + ; + }); + + test('default strict mode allows returning InvariantTestBox when method specifies use-site ', function () { + $result = produceUseSiteCovariantAnimalBox(); + + expect($result)->toBeInstanceOf(InvariantTestBox::class); + }); + + test('pragmatic mode allows returning InvariantTestBox and WeakMap still prevents illegal caller mutations', function () { + Config::set(['strict_return_generic_invariance' => false]); + + $box = produceAnimalBox(); + expect($box)->toBeInstanceOf(InvariantTestBox::class); + + expect(fn () => $box->add(new Cat())) + ->toThrow(TypeError::class, 'Argument $item (template T = TypePHP\Tests\Fixtures\Domain\Dog) must be of type TypePHP\Tests\Fixtures\Domain\Dog, TypePHP\Tests\Fixtures\Domain\Cat given') + ; + }); +});