From d3ef8f74890591b8a45ad322283aef54330a000f Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 10 Sep 2026 13:40:28 +0800 Subject: [PATCH] Improve hot path performance --- src/Internal/Ast/FunctionContractInjector.php | 114 ++++++++--- src/Internal/Checker/ParamChecker.php | 69 +++---- src/Internal/Checker/ReturnChecker.php | 69 ++++--- src/Internal/Docblock/DocblockParser.php | 178 +++++++++++++++++- src/Internal/Io/StreamWrapper.php | 3 + src/Internal/Resolver/HierarchyResolver.php | 58 +++++- src/Internal/RuntimeTypeChecker.php | 72 +++++-- tests/Internal/Util/IgnoreManagerTest.php | 2 +- 8 files changed, 446 insertions(+), 119 deletions(-) diff --git a/src/Internal/Ast/FunctionContractInjector.php b/src/Internal/Ast/FunctionContractInjector.php index d01d392a..40b40706 100644 --- a/src/Internal/Ast/FunctionContractInjector.php +++ b/src/Internal/Ast/FunctionContractInjector.php @@ -46,12 +46,12 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node, ? $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'; $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; $isPrivate = $isClassMethod && $node->isPrivate(); $paramCount = \count($node->params); + $hasParam = self::hasParamContracts( $docText, $isClassMethod, @@ -184,7 +184,6 @@ private static function hasNonMixedParam(string $docText): bool break; } } - if (! $hasMixed) { return true; } @@ -281,7 +280,6 @@ private static function buildParamInjections( Node\Expr $thisArg ): array { $injectedStmts = [self::buildSetupScopeStmt($params, $thisArg)]; - $callableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isCallableCandidate'], 'wrapCallable'); $iterableWrappers = self::buildParamWrappers($params, $docText, $thisArg, [self::class, 'isIterableCandidate'], 'wrapIterable'); @@ -304,28 +302,65 @@ private static function buildSetupScopeStmt(array $params, Node\Expr $thisArg): } } - $argsExpr = new Node\Expr\Assign( - new Node\Expr\Variable('__typephpArgs'), - new Node\Expr\Array_($arrayItems) + $argsAssign = new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\Variable('_typephpArgs'), + new Node\Expr\Array_($arrayItems) + ) ); $checkCall = new Node\Expr\FuncCall( new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::setupScope'), [ new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg($argsExpr), + new Node\Arg(new Node\Expr\Variable('_typephpArgs')), new Node\Arg($thisArg), ] ); $throwStmt = self::buildTypeErrorThrowStmt(new Node\Expr\Variable('__typephpErr')); - $ifStmt = new Node\Stmt\If_( - new Node\Expr\Instanceof_( - new Node\Expr\Assign(new Node\Expr\Variable('__typephpErr'), $checkCall), - new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') + $cacheKeyExpr = new Node\Scalar\MagicConst\Method(); + + $noParamCacheCheck = new Node\Expr\BooleanNot( + new Node\Expr\Isset_([ + new Node\Expr\ArrayDimFetch( + new Node\Expr\StaticPropertyFetch( + new Node\Name\FullyQualified('TypePHP\Internal\Checker\ParamChecker'), + 'noParamContractCache' + ), + $cacheKeyExpr + ), + ]) + ); + + $hasTemplatesCheck = new Node\Expr\BinaryOp\Coalesce( + new Node\Expr\ArrayDimFetch( + new Node\Expr\StaticPropertyFetch( + new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'), + 'hasMethodTemplatesCache' + ), + $cacheKeyExpr ), - ['stmts' => [$throwStmt]] + new Node\Expr\ConstFetch(new Node\Name('false')) + ); + + $combinedCondition = new Node\Expr\BinaryOp\BooleanOr($noParamCacheCheck, $hasTemplatesCheck); + + $ifStmt = new Node\Stmt\If_( + $combinedCondition, + [ + 'stmts' => [ + $argsAssign, + new Node\Stmt\If_( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpErr'), $checkCall), + new Node\Name\FullyQualified('TypePHP\Internal\Diagnostic\ErrorMessage') + ), + ['stmts' => [$throwStmt]] + ), + ], + ] ); $ifStmt->setAttribute('typephp_injected', true); @@ -347,6 +382,7 @@ private static function buildParamWrappers( string $wrapperMethod ): array { $wrappers = []; + foreach ($params as $param) { if ($predicate($param, $docText) && $param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { $paramName = $param->var->name; @@ -354,7 +390,7 @@ private static function buildParamWrappers( new Node\Expr\Assign( new Node\Expr\Variable($paramName), new Node\Expr\FuncCall( - new Node\Name\FullyQualified("TypePHP\\Internal\\RuntimeTypeChecker::{$wrapperMethod}"), + new Node\Name\FullyQualified("TypePHP\Internal\RuntimeTypeChecker::{$wrapperMethod}"), [ new Node\Arg(new Node\Scalar\MagicConst\Method()), new Node\Arg(new Node\Scalar\String_($paramName)), @@ -408,11 +444,9 @@ private static function typeMatchesName(Node\Identifier|Node\Name|Node\ComplexTy if ($type instanceof Node\Identifier) { return isset($targetNames[strtolower($type->name)]); } - if ($type instanceof Node\Name) { return isset($targetNames[strtolower($type->getLast())]); } - if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { foreach ($type->types as $t) { if (self::typeMatchesName($t, $targetNames)) { @@ -460,7 +494,7 @@ public static function buildTypeErrorThrowStmt(Node\Expr $errorVar): Node\Stmt\E public static function buildReturnCheckCall(Node\Expr $exprToWrap, Node\Expr $thisArg, bool $needsReturnVars = false): Node\Expr\FuncCall { $varsArg = $needsReturnVars - ? new Node\Expr\Variable('__typephpArgs') + ? new Node\Expr\Variable('_typephpArgs') : new Node\Expr\Array_(); return new Node\Expr\FuncCall( @@ -569,7 +603,6 @@ public function enterNode(Node $n): int|Node|null if ($n->getAttribute('typephp_wrapped') === true) { return null; } - $n->setAttribute('typephp_wrapped', true); return FunctionContractInjector::buildWrappedYieldNode($n, $this->thisArg); @@ -579,9 +612,7 @@ public function enterNode(Node $n): int|Node|null if ($n->getAttribute('typephp_wrapped') === true) { return null; } - $n->setAttribute('typephp_wrapped', true); - $n->expr = new Node\Expr\FuncCall( new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker::wrapIterable'), [ @@ -629,15 +660,34 @@ public function enterNode(Node $n): int|array|null if ($n->getAttribute('typephp_var_wrapped') === true) { return null; } - $exprToWrap = $n->expr ?? new Node\Expr\ConstFetch(new Node\Name('null')); - $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars); if ($this->isNativeVoid) { + $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars); + return FunctionContractInjector::buildVoidReturnGuard($checkCall); } - $n->expr = FunctionContractInjector::buildTernaryReturnExpr($checkCall); + // Call-site cache bypass for return checks + $cacheKeyExpr = new Node\Scalar\MagicConst\Method(); + $cacheCheck = new Node\Expr\Isset_([ + new Node\Expr\ArrayDimFetch( + new Node\Expr\StaticPropertyFetch( + new Node\Name\FullyQualified('TypePHP\Internal\Checker\ReturnChecker'), + 'noReturnContractCache' + ), + $cacheKeyExpr + ), + ]); + + $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars); + $ternaryExpr = FunctionContractInjector::buildTernaryReturnExpr($checkCall); + + $n->expr = new Node\Expr\Ternary( + $cacheCheck, + $exprToWrap, + $ternaryExpr + ); } return null; @@ -654,7 +704,25 @@ public function enterNode(Node $n): int|array|null if ($isNativeVoid) { $newStmts = [...$newStmts, ...self::buildVoidReturnGuard($checkCall)]; } else { - $retStmt = new Node\Stmt\Return_(self::buildTernaryReturnExpr($checkCall)); + $cacheKeyExpr = new Node\Scalar\MagicConst\Method(); + $cacheCheck = new Node\Expr\Isset_([ + new Node\Expr\ArrayDimFetch( + new Node\Expr\StaticPropertyFetch( + new Node\Name\FullyQualified('TypePHP\Internal\Checker\ReturnChecker'), + 'noReturnContractCache' + ), + $cacheKeyExpr + ), + ]); + + $ternaryExpr = self::buildTernaryReturnExpr($checkCall); + $fallbackExpr = new Node\Expr\Ternary( + $cacheCheck, + new Node\Expr\ConstFetch(new Node\Name('null')), + $ternaryExpr + ); + + $retStmt = new Node\Stmt\Return_($fallbackExpr); $retStmt->setAttribute('typephp_injected', true); $newStmts[] = $retStmt; } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 880672cd..098f1cb8 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -49,13 +49,6 @@ final class ParamChecker */ 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. */ @@ -65,12 +58,11 @@ public static function reset(): void 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. + * Uses the pre-computed flag from DocblockParser contract when available. */ public static function areAllParamsUnconstrained(string $effectiveFunction): bool { @@ -78,21 +70,9 @@ public static function areAllParamsUnconstrained(string $effectiveFunction): boo 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; - } + $contract = DocblockParser::parse($effectiveFunction); - return self::$allParamsUnconstrainedCache[$cacheKey]; + return $contract['allParamsUnconstrained'] ?? false; } /** @@ -103,7 +83,6 @@ private static function isUnconstrained(TypeNode $typeNode): bool if (! ($typeNode instanceof IdentifierTypeNode)) { return false; } - $lower = strtolower($typeNode->name); return $lower === 'mixed' || $lower === 'array'; @@ -111,19 +90,38 @@ private static function isUnconstrained(TypeNode $typeNode): bool /** * @param array $vars + * @param 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, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * }|null $contract Pre-resolved contract to avoid re-parsing */ public static function checkParams( string $function, array $vars, object|string|null $thisOrClass, TypeValidatorRegistry $registry, - string $effectiveFunction = '' + string $effectiveFunction = '', + ?array $contract = null ): ?ErrorMessage { if (! Config::isParamsEnabled()) { return null; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + if ($effectiveFunction === '') { $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); } @@ -142,12 +140,12 @@ public static function checkParams( if ($magicError !== null) { return $magicError; } - if ($isMagicCall) { return null; } - $contract = DocblockParser::parse($effectiveFunction); + // Use pre-resolved contract or parse + $contract ??= DocblockParser::parse($effectiveFunction); if (! $contract['hasParamContract']) { self::$noParamContractCache[$effectiveFunction] = true; @@ -341,6 +339,7 @@ public static function resolveEffectiveFunction(string $function, object|string| } $actualClassName = \is_object($thisOrClass) ? $thisOrClass::class : (\is_string($thisOrClass) ? $thisOrClass : ''); + if ($actualClassName === '') { return $function; } @@ -498,9 +497,11 @@ private static function inferTemplatesFromClosures( $targetObj = $isClassLevel ? $thisObj : null; $inferredCandidate = self::extractTypeFromClosureParameter($closureParams[$idx]); + if ($inferredCandidate !== null) { $templateTag = $templates[$tName]; $satisfiesBound = true; + if ($templateTag->bound !== null) { $resolvedBound = SpecialTypeResolver::resolve($templateTag->bound, $effectiveFunction, $thisObj); $satisfiesBound = TemplateManager::checkVariance($inferredCandidate, $resolvedBound, GenericTypeNode::VARIANCE_COVARIANT); @@ -526,7 +527,6 @@ private static function inferTemplatesFromClosures( private static function extractCallableNodes(array $types): array { $callableNodes = []; - foreach ($types as $paramName => $tNode) { if ($tNode instanceof CallableTypeNode) { $callableNodes[$paramName] = $tNode; @@ -625,6 +625,7 @@ private static function inferArrayTemplatesFromAllElements( } $sampleItems = self::getSampleArraySlice($arrVal); + $genericCount = \count($typeNode->genericTypes); if ($genericCount === 1 && $typeNode->genericTypes[0] instanceof IdentifierTypeNode) { @@ -669,7 +670,6 @@ private static function inferSingleTemplateFromArraySamples( array $templates ): void { $inferredType = null; - foreach ($sampleItems as $item) { $itemType = TemplateManager::inferTypeFromValue($item); $inferredType = ($inferredType === null) ? $itemType : self::unifyTypes($inferredType, $itemType); @@ -697,7 +697,6 @@ private static function getSampleArraySlice(array $arrVal): array $keys = array_keys($arrVal); $sampleKeys = [$keys[0], $keys[$count - 1]]; $samplesToTake = min(3, $count - 2); - for ($i = 0; $i < $samplesToTake; $i++) { $sampleKeys[] = $keys[mt_rand(1, $count - 2)]; } @@ -792,7 +791,6 @@ private static function validateMagicArguments( $templates = $magicContract['templates']; $aliases = $magicContract['aliases']; $parameters = $magicContract['parameters']; - $argValues = array_values($args); $argKeys = array_keys($args); @@ -910,7 +908,6 @@ private static function resolveClassStringTemplate( $innerType = $typeNode->genericTypes[0]; $templateName = $innerType->name; $templateNode = $templates[$templateName]; - $isClassLevelTemplate = isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; @@ -1007,7 +1004,6 @@ private static function getTemplateName(TypeNode $typeNode, array $templates): ? if ($t0 instanceof IdentifierTypeNode && isset($templates[$t0->name]) && $t1 instanceof IdentifierTypeNode && strtolower($t1->name) === 'null') { return $t0->name; } - if ($t1 instanceof IdentifierTypeNode && isset($templates[$t1->name]) && $t0 instanceof IdentifierTypeNode && strtolower($t0->name) === 'null') { return $t1->name; } @@ -1038,10 +1034,8 @@ private static function resolveTemplateParam( $templateNode = $templates[$templateName]; $isVariadic = $typeNode instanceof ArrayTypeNode; $isNullable = ($typeNode instanceof NullableTypeNode) || ($typeNode instanceof UnionTypeNode && self::typeContainsNull($typeNode)); - $isClassLevelTemplate = isset($classTemplates[$templateName]); $targetObj = $isClassLevelTemplate ? $thisObj : null; - $allowsNullInBound = ($templateNode->bound !== null && self::typeContainsNull($templateNode->bound)); if ($isNullable && $val === null) { @@ -1218,11 +1212,9 @@ private static function validateVariadicList( $err, $registry ); - if ($widenErr !== null) { return $widenErr; } - $currentType = TemplateManager::getBoundType($function, $targetObj, $templateName) ?? $currentType; } } @@ -1248,6 +1240,7 @@ private static function tryWidenTemplate( } $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); + $boundErr = $registry->validate($val, $resolvedBound, $context . ' (template ' . $templateName . ')'); if ($boundErr === null) { @@ -1271,7 +1264,6 @@ private static function unifyTypes(TypeNode $type1, TypeNode $type2): TypeNode } $types = []; - if ($type1 instanceof UnionTypeNode) { $types = $type1->types; } else { @@ -1288,7 +1280,6 @@ private static function unifyTypes(TypeNode $type1, TypeNode $type2): TypeNode $unique = []; $deduped = []; - foreach ($types as $t) { $str = (string) $t; if (! isset($unique[$str])) { diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 51354e10..620de13e 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -56,13 +56,6 @@ final class ReturnChecker */ 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. */ @@ -72,12 +65,11 @@ public static function reset(): void 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. + * Uses the pre-computed flag from DocblockParser contract when available. */ public static function isReturnUnconstrained(string $effectiveFunction): bool { @@ -85,25 +77,30 @@ public static function isReturnUnconstrained(string $effectiveFunction): bool 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; - } + $contract = DocblockParser::parse($effectiveFunction); - return self::$returnUnconstrainedCache[$cacheKey]; + return $contract['returnUnconstrained'] ?? false; } /** * @param array $vars + * @param 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, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * }|null $contract Pre-resolved contract to avoid re-parsing */ public static function checkReturn( string $function, @@ -111,7 +108,9 @@ public static function checkReturn( object|string|null $thisOrClass, array $vars, TypeValidatorRegistry $registry, - callable $wrapIterableCallback + callable $wrapIterableCallback, + string $effectiveFunction = '', + ?array $contract = null ): mixed { if (! Config::isReturnsEnabled()) { return $value; @@ -122,7 +121,10 @@ public static function checkReturn( } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if ($effectiveFunction === '') { + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + } if (isset(self::$noReturnContractCache[$effectiveFunction])) { self::$noReturnContractCache[$function] = true; @@ -131,7 +133,6 @@ public static function checkReturn( } $isMagicCall = str_contains($effectiveFunction, '__call'); - $magicResult = self::handleMagicReturn( $effectiveFunction, $value, @@ -140,16 +141,15 @@ public static function checkReturn( $registry, $wrapIterableCallback ); - if ($magicResult !== null) { return $magicResult; } - if ($isMagicCall) { return $value; } - $contract = DocblockParser::parse($effectiveFunction); + // Use pre-resolved contract or parse + $contract ??= DocblockParser::parse($effectiveFunction); if (! ($contract['hasReturnContract'] ?? ($contract['return'] !== null))) { self::$noReturnContractCache[$effectiveFunction] = true; @@ -214,7 +214,6 @@ private static function handleMagicReturn( $className = explode('::', $effectiveFunction, 2)[0]; $magicContract = DocblockParser::parseMagicMethod($className, $magicMethodName); - if ($magicContract === null || $magicContract['return'] === null) { return null; } @@ -247,7 +246,10 @@ private static function handleMagicReturn( * returnIsDynamic?: bool, * aliases?: array, * templates?: array, - * classTemplates?: array + * classTemplates?: array, + * allParamsUnconstrained?: bool, + * returnUnconstrained?: bool, + * isSimple?: bool * } $contract */ private static function evaluateReturn( @@ -422,7 +424,6 @@ private static function resolveParameterConditional( ): TypeNode { $paramName = ltrim($node->parameterName, '$'); $paramValue = null; - if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { $paramValue = $vars[$paramName]; } elseif (\count($vars) > 0 && $function !== '' && str_contains($function, '::')) { @@ -431,7 +432,6 @@ private static function resolveParameterConditional( $targetErr = $registry->validate($paramValue, $node->targetType, 'condition'); $isTargetMatch = ($targetErr === null); - if ($node->negated) { $isTargetMatch = ! $isTargetMatch; } @@ -449,7 +449,6 @@ private static function resolveParameterConditional( private static function resolveRenamedParamValue(string $function, string $paramName, array $vars): mixed { [$className, $methodName] = explode('::', $function, 2); - if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { return null; } @@ -501,13 +500,11 @@ private static function resolveTemplateConditional( TypeValidatorRegistry $registry ): TypeNode { $subjectTypeNode = $node->subjectType; - if ($subjectTypeNode instanceof IdentifierTypeNode && isset($boundTemplates[$subjectTypeNode->name])) { $subjectTypeNode = $boundTemplates[$subjectTypeNode->name]; } $isTargetMatch = TemplateManager::checkVariance($subjectTypeNode, $node->targetType, GenericTypeNode::VARIANCE_COVARIANT); - if ($node->negated) { $isTargetMatch = ! $isTargetMatch; } diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 5d76d37c..a29df9e3 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -37,7 +37,23 @@ final class DocblockParser /** * Cache for resolved contract metadata. * - * @var array, templates: array, classTemplates: array, return: ?TypeNode, aliases: array, hasParamContract: bool, hasReturnContract: bool, paramsUseGenerics: bool, returnUsesGenerics: bool}> + * @var array, + * templates: array, + * classTemplates: array, + * return: ?TypeNode, + * aliases: array, + * hasParamContract: bool, + * hasReturnContract: bool, + * paramsUseGenerics: bool, + * returnUsesGenerics: bool, + * returnUsesMethodTemplates: bool, + * returnIsThis: bool, + * returnIsDynamic: bool, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * }> */ private static array $cache = []; @@ -228,10 +244,85 @@ public static function typeReferencesTemplate(?TypeNode $node, array $templateNa return false; } + /** + * Checks if a TypeNode represents an unconstrained type (mixed or array). + */ + private static function isUnconstrainedType(TypeNode $typeNode): bool + { + if (! ($typeNode instanceof IdentifierTypeNode)) { + return false; + } + $lower = strtolower($typeNode->name); + + return $lower === 'mixed' || $lower === 'array'; + } + + /** + * Computes pre-optimized flags for a contract to avoid redundant re-parsing. + * + * @param array $types + * @param array $aliases + * @param array $methodTemplates + * + * @return array{allParamsUnconstrained: bool, returnUnconstrained: bool, isSimple: bool} + */ + private static function computeContractFlags( + array $types, + ?TypeNode $returnType, + bool $paramsUseGenerics, + bool $returnUsesGenerics, + array $aliases, + array $methodTemplates + ): array { + $allParamsUnconstrained = true; + if (\count($types) > 0) { + foreach ($types as $tNode) { + if (! self::isUnconstrainedType($tNode)) { + $allParamsUnconstrained = false; + + break; + } + } + } + + $returnUnconstrained = false; + if ($returnType instanceof IdentifierTypeNode) { + $lower = strtolower($returnType->name); + $returnUnconstrained = ($lower === 'mixed' || $lower === 'array'); + } + + $isSimple = ! $paramsUseGenerics + && ! $returnUsesGenerics + && \count($aliases) === 0 + && \count($methodTemplates) === 0; + + return [ + 'allParamsUnconstrained' => $allParamsUnconstrained, + 'returnUnconstrained' => $returnUnconstrained, + 'isSimple' => $isSimple, + ]; + } + /** * 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, paramsUseGenerics: bool, returnUsesGenerics: 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, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * } */ public static function parse(string $function): array { @@ -263,6 +354,12 @@ public static function parse(string $function): array 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, + 'returnUsesMethodTemplates' => false, + 'returnIsThis' => false, + 'returnIsDynamic' => false, + 'allParamsUnconstrained' => true, + 'returnUnconstrained' => false, + 'isSimple' => true, ]; } } else { @@ -276,6 +373,12 @@ public static function parse(string $function): array 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, + 'returnUsesMethodTemplates' => false, + 'returnIsThis' => false, + 'returnIsDynamic' => false, + 'allParamsUnconstrained' => true, + 'returnUnconstrained' => false, + 'isSimple' => true, ]; } } else { @@ -293,6 +396,12 @@ public static function parse(string $function): array 'hasReturnContract' => false, 'paramsUseGenerics' => false, 'returnUsesGenerics' => false, + 'returnUsesMethodTemplates' => false, + 'returnIsThis' => false, + 'returnIsDynamic' => false, + 'allParamsUnconstrained' => true, + 'returnUnconstrained' => false, + 'isSimple' => true, ]; } @@ -637,7 +746,23 @@ 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, paramsUseGenerics: bool, returnUsesGenerics: bool, returnUsesMethodTemplates: bool, returnIsThis: bool, returnIsDynamic: 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, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * } */ private static function parseMethod(\ReflectionMethod $ref): array { @@ -685,6 +810,16 @@ private static function parseMethod(\ReflectionMethod $ref): array $returnIsDynamic = $returnIsThis || str_contains($retStr, 'static') || str_contains($retStr, '$this'); } + // Compute pre-optimized flags + $flags = self::computeContractFlags( + $types, + $returnType, + $paramsUseGenerics, + $returnUsesGenerics, + $aliases, + $methodTemplates + ); + return [ 'types' => $types, 'templates' => $methodTemplates, @@ -698,13 +833,32 @@ private static function parseMethod(\ReflectionMethod $ref): array 'returnUsesMethodTemplates' => $returnUsesMethodTemplates, 'returnIsThis' => $returnIsThis, 'returnIsDynamic' => $returnIsDynamic, + 'allParamsUnconstrained' => $flags['allParamsUnconstrained'], + 'returnUnconstrained' => $flags['returnUnconstrained'], + 'isSimple' => $flags['isSimple'], ]; } /** * Orchestrates parsing for standalone global or namespaced functions. * - * @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} + * @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, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * } */ private static function parseFunction(\ReflectionFunction $ref): array { @@ -731,6 +885,9 @@ private static function parseFunction(\ReflectionFunction $ref): array 'returnUsesMethodTemplates' => false, 'returnIsThis' => false, 'returnIsDynamic' => false, + 'allParamsUnconstrained' => true, + 'returnUnconstrained' => false, + 'isSimple' => true, ]; } @@ -813,6 +970,16 @@ private static function parseFunction(\ReflectionFunction $ref): array $returnIsDynamic = $returnIsThis || str_contains($retStr, 'static') || str_contains($retStr, '$this'); } + // Compute pre-optimized flags + $flags = self::computeContractFlags( + $types, + $returnType, + $paramsUseGenerics, + $returnUsesMethodTemplates, + $aliases, + $templates + ); + return [ 'types' => $types, 'templates' => $templates, @@ -826,6 +993,9 @@ private static function parseFunction(\ReflectionFunction $ref): array 'returnUsesMethodTemplates' => $returnUsesMethodTemplates, 'returnIsThis' => $returnIsThis, 'returnIsDynamic' => $returnIsDynamic, + 'allParamsUnconstrained' => $flags['allParamsUnconstrained'], + 'returnUnconstrained' => $flags['returnUnconstrained'], + 'isSimple' => $flags['isSimple'], ]; } diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index 70082080..fd0fb8fb 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -511,6 +511,9 @@ public function stream_close(): void * 1. Differentiates between stat() and lstat() (STREAM_URL_STAT_LINK). * 2. Memoizes PHP files, vendor files, and static package source directories. * + * NOTE: stat() and lstat() DO trigger recursive url_stat calls when the + * file:// wrapper is registered. The unregister/register cycle is REQUIRED. + * * @return array|false */ public function url_stat(string $path, int $flags): array|false diff --git a/src/Internal/Resolver/HierarchyResolver.php b/src/Internal/Resolver/HierarchyResolver.php index 89dcd211..3f153e7a 100644 --- a/src/Internal/Resolver/HierarchyResolver.php +++ b/src/Internal/Resolver/HierarchyResolver.php @@ -33,6 +33,22 @@ final class HierarchyResolver */ private static array $traitAliasCache = []; + /** + * Boolean fast-path cache: true if class has trait aliases, false if not. + * Avoids array allocation and comparison for the common "no traits" case. + * + * @var array + */ + private static array $hasTraitAliasesCache = []; + + /** + * Cache for 4-way class/interface/trait/enum existence checks. + * Avoids repeated existence checks for the same class name. + * + * @var array + */ + private static array $classExistsCache = []; + /** * Resets the hierarchy cache. Useful for test isolation. */ @@ -41,29 +57,67 @@ public static function reset(): void self::$methodHierarchyCache = []; self::$classHierarchyCache = []; self::$traitAliasCache = []; + self::$hasTraitAliasesCache = []; + self::$classExistsCache = []; + } + + /** + * Checks whether a class, interface, trait, or enum exists. + * Caches the result to avoid repeated 4-way existence checks. + */ + private static function typeExists(string $className): bool + { + if (isset(self::$classExistsCache[$className])) { + return self::$classExistsCache[$className]; + } + + $exists = class_exists($className) + || interface_exists($className) + || trait_exists($className) + || enum_exists($className); + + return self::$classExistsCache[$className] = $exists; } /** * Returns cached trait aliases for a given class. * + * Optimized with boolean fast-path cache to avoid reflection + * for classes known to not use traits. + * * @return array */ public static function getTraitAliases(string $className): array { + if (isset(self::$hasTraitAliasesCache[$className])) { + if (! self::$hasTraitAliasesCache[$className]) { + return []; + } + + return self::$traitAliasCache[$className]; + } + if (isset(self::$traitAliasCache[$className])) { return self::$traitAliasCache[$className]; } - if (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className) && ! enum_exists($className)) { + if (! self::typeExists($className)) { + self::$hasTraitAliasesCache[$className] = false; + return self::$traitAliasCache[$className] = []; } try { /** @var class-string $className */ $ref = new ReflectionClass($className); + $aliases = $ref->getTraitAliases(); + + self::$hasTraitAliasesCache[$className] = ($aliases !== []); - return self::$traitAliasCache[$className] = $ref->getTraitAliases(); + return self::$traitAliasCache[$className] = $aliases; } catch (\Throwable $e) { + self::$hasTraitAliasesCache[$className] = false; + return self::$traitAliasCache[$className] = []; } } diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 4615f444..ddef5405 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -28,9 +28,12 @@ final class RuntimeTypeChecker private static ?TypeValidatorRegistry $registry = null; /** + * Cache for whether a method's return type uses method-level templates. + * MUST be public so injected AST code can read it for call-site cache bypass. + * * @var array */ - private static array $hasMethodTemplatesCache = []; + public static array $hasMethodTemplatesCache = []; /** * Resets runtime caches. @@ -125,30 +128,46 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string /** * Initialises generic call frames and returns a ScopeCleaner that pops the call frame on destruction. * + * Optimized: combines Config checks, uses pre-computed contract flags, + * and passes contract through to checkParams to avoid re-parsing. + * * @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)) { + // Combined config gate — single check instead of two separate calls + if (! Config::isEnabled() || ! Config::isParamsEnabled()) { return null; } - if (! Config::isEnabled()) { + if (isset(ParamChecker::$noParamContractCache[$function]) + && ! (self::$hasMethodTemplatesCache[$function] ?? false)) { return null; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - if (ParamChecker::areAllParamsUnconstrained($effectiveFunction)) { + // FIX: Magic methods (__call/__callStatic) must NOT bail out early, + // even if __call itself has unconstrained (mixed) parameters, + // because the actual validation happens inside ParamChecker::handleMagicCall(). + $isMagicCall = str_contains($effectiveFunction, '__call'); + + // Fast bail using pre-computed flag from contract (skip for magic calls) + $contract = DocblockParser::parse($effectiveFunction); + if (! $isMagicCall && ($contract['allParamsUnconstrained'] ?? false)) { return null; } - $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry(), $effectiveFunction); + // Pass effectiveFunction AND contract to checkParams to avoid re-parsing + $err = ParamChecker::checkParams( + $function, + $vars, + $thisOrClass, + self::getRegistry(), + $effectiveFunction, + $contract + ); if ($err !== null) { if (IgnoreManager::isCallerIgnored()) { @@ -162,7 +181,6 @@ public static function setupScope(string $function, array $vars, object|string|n $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] ?? null; if ($hasMethodTemplates === null) { - $contract = DocblockParser::parse($effectiveFunction); $hasMethodTemplates = self::$hasMethodTemplatesCache[$effectiveFunction] = ( $contract['returnUsesMethodTemplates'] ?? false ); @@ -195,28 +213,54 @@ 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. * + * Optimized: combines Config checks, resolves effectiveFunction once, + * uses pre-computed contract flags, and passes both effectiveFunction and contract + * to ReturnChecker to avoid redundant resolution and parsing. + * * @param array|null $vars */ public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass = null, ?array $vars = []): mixed { - if (isset(ReturnChecker::$noReturnContractCache[$function])) { + // Combined config gate — single check instead of two separate calls + if (! Config::isEnabled() || ! Config::isReturnsEnabled()) { return $value; } - if (! Config::isEnabled()) { + if (isset(ReturnChecker::$noReturnContractCache[$function])) { return $value; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - if (ReturnChecker::isReturnUnconstrained($effectiveFunction)) { + if (isset(ReturnChecker::$noReturnContractCache[$effectiveFunction])) { + ReturnChecker::$noReturnContractCache[$function] = true; + + return $value; + } + + // FIX: Magic methods must NOT bail out early + $isMagicCall = str_contains($effectiveFunction, '__call'); + + // Fast bail using pre-computed flag from contract (skip for magic calls) + $contract = DocblockParser::parse($effectiveFunction); + if (! $isMagicCall && ($contract['returnUnconstrained'] ?? false)) { return $value; } $vars ??= []; - $res = ReturnChecker::checkReturn($function, $value, $thisOrClass, $vars, self::getRegistry(), [self::class, 'wrapIterable']); + // Pass effectiveFunction AND contract to ReturnChecker to avoid re-parsing + $res = ReturnChecker::checkReturn( + $function, + $value, + $thisOrClass, + $vars, + self::getRegistry(), + [self::class, 'wrapIterable'], + $effectiveFunction, + $contract + ); if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { return $value; diff --git a/tests/Internal/Util/IgnoreManagerTest.php b/tests/Internal/Util/IgnoreManagerTest.php index 67557f6e..179d4cdb 100644 --- a/tests/Internal/Util/IgnoreManagerTest.php +++ b/tests/Internal/Util/IgnoreManagerTest.php @@ -241,4 +241,4 @@ public static function execute(): bool expect(NormalCallerFixture::execute())->toBeFalse(); }); }); -}); \ No newline at end of file +});