From 422af544a09136d0d8ac221fb7d4c93d1d1f8e47 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Wed, 19 Aug 2026 00:07:55 +0800 Subject: [PATCH] Improve performance by optimizing and memoizing hot paths --- src/Contract/ContractParser.php | 14 ++- src/Contract/DocblockExtractor.php | 29 ++++- src/Contract/FileFilter.php | 116 ++++++++++++++++---- src/Internal/Checker/ParamChecker.php | 33 ++++-- src/Internal/Checker/ReturnChecker.php | 29 +++-- src/Internal/Config.php | 98 ++++++++++++++++- src/Internal/RuntimeTypeChecker.php | 35 +++--- src/Resolver/SpecialTypeResolver.php | 139 ++++++++++++------------ src/Validator/TypeValidatorRegistry.php | 21 +++- 9 files changed, 374 insertions(+), 140 deletions(-) diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 744f329..2a9655a 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -20,6 +20,7 @@ use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use TypePHP\Internal\Config; use TypePHP\Resolver\SpecialTypeResolver; +use TypePHP\Validator\TypeValidatorRegistry; /** * @internal Main orchestrator parsing and caching PHPDoc contracts (@param, @return, @template, @phpstan-type, @var). @@ -55,6 +56,9 @@ public static function reset(): void self::$cache = []; self::$propertyCache = []; self::$magicMethodCache = []; + DocblockExtractor::reset(); + FileFilter::reset(); + TypeValidatorRegistry::reset(); } /** @@ -707,7 +711,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof CallableTypeNode) { $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode( self::substituteAliases($param->type, $aliases), $param->isReference, $param->isVariadic, @@ -741,7 +745,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof GenericTypeNode) { $genericType = self::substituteAliases($node->type, $aliases); $genericTypes = array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->genericTypes ); @@ -758,14 +762,14 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo if ($node instanceof UnionTypeNode) { return new UnionTypeNode(array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types )); } if ($node instanceof IntersectionTypeNode) { return new IntersectionTypeNode(array_map( - fn ($t) => self::substituteAliases($t, $aliases), + fn($t) => self::substituteAliases($t, $aliases), $node->types )); } @@ -794,4 +798,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} \ No newline at end of file +} diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 0e8116a..833faba 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -26,6 +26,21 @@ */ final class DocblockExtractor { + /** + * In-memory cache for normalized and parsed PhpDocNode ASTs keyed by raw docblock string. + * + * @var array + */ + private static array $docParseCache = []; + + /** + * Resets the parsed docblock cache. Useful for test isolation. + */ + public static function reset(): void + { + self::$docParseCache = []; + } + /** * Returns shared static instances of PHPStan's PhpDocParser and Lexer. * @@ -50,15 +65,19 @@ public static function getParserComponents(): array } /** - * Normalizes and parses a PHPDoc doccomment string into an AST PhpDocNode. + * Normalizes and parses a PHPDoc doccomment string into an AST PhpDocNode with in-memory memoization. */ public static function parseDocString(string $doc): PhpDocNode { - $doc = DocblockNormalizer::normalize($doc); + if (isset(self::$docParseCache[$doc])) { + return self::$docParseCache[$doc]; + } + + $normalized = DocblockNormalizer::normalize($doc); [$phpDocParser, $lexer] = self::getParserComponents(); - $tokens = new TokenIterator($lexer->tokenize($doc)); + $tokens = new TokenIterator($lexer->tokenize($normalized)); - return $phpDocParser->parse($tokens); + return self::$docParseCache[$doc] = $phpDocParser->parse($tokens); } /** @@ -405,4 +424,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam return null; } -} +} \ No newline at end of file diff --git a/src/Contract/FileFilter.php b/src/Contract/FileFilter.php index 78f4248..c34a529 100644 --- a/src/Contract/FileFilter.php +++ b/src/Contract/FileFilter.php @@ -12,6 +12,43 @@ */ final class FileFilter { + /** + * In-memory cache of boolean exclusion results keyed by normalized file path. + * + * @var array + */ + private static array $pathFilterCache = []; + + /** + * Pre-compiled include regex patterns and match lengths. + * + * @var array|null + */ + private static ?array $compiledIncludes = null; + + /** + * Pre-compiled exclude regex patterns and match lengths. + * + * @var array|null + */ + private static ?array $compiledExcludes = null; + + /** + * Cached normalized cache directory path. + */ + private static ?string $cachedCacheDir = null; + + /** + * Resets the path decision cache and pre-compiled regex patterns. Useful for test isolation. + */ + public static function reset(): void + { + self::$pathFilterCache = []; + self::$compiledIncludes = null; + self::$compiledExcludes = null; + self::$cachedCacheDir = null; + } + /** * Determines whether a given file path is excluded from contract inheritance. * Non-PHP files and excluded paths return true. @@ -24,16 +61,54 @@ public static function isFileExcluded(string|false|null $fileName): bool $normalizedPath = str_replace('\\', '/', $fileName); + if (isset(self::$pathFilterCache[$normalizedPath])) { + return self::$pathFilterCache[$normalizedPath]; + } + // Non-PHP files are always excluded from PHPDoc contract processing if (! str_ends_with(strtolower($normalizedPath), '.php')) { - return true; + return self::$pathFilterCache[$normalizedPath] = true; } - $normalizedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/'; - if (str_starts_with($normalizedPath, $normalizedCacheDir)) { - return true; + if (self::$cachedCacheDir === null) { + self::$cachedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/'; } + if (str_starts_with($normalizedPath, self::$cachedCacheDir)) { + return self::$pathFilterCache[$normalizedPath] = true; + } + + if (self::$compiledIncludes === null || self::$compiledExcludes === null) { + self::compilePatterns(); + } + + $longestIncludeMatch = 0; + /** @var array $includes */ + $includes = self::$compiledIncludes; + foreach ($includes as $compiled) { + if (preg_match($compiled['regex'], $normalizedPath) === 1) { + $longestIncludeMatch = max($longestIncludeMatch, $compiled['len']); + } + } + + $longestExcludeMatch = 0; + /** @var array $excludes */ + $excludes = self::$compiledExcludes; + foreach ($excludes as $compiled) { + if (preg_match($compiled['regex'], $normalizedPath) === 1) { + $longestExcludeMatch = max($longestExcludeMatch, $compiled['len']); + } + } + + // Equal specificity tie-breaker: Exclude wins! + return self::$pathFilterCache[$normalizedPath] = ($longestExcludeMatch >= $longestIncludeMatch); + } + + /** + * Compiles configured include and exclude globs into regex patterns once per configuration lifecycle. + */ + private static function compilePatterns(): void + { $config = Config::get(); /** @var array $includes */ $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; @@ -42,30 +117,27 @@ public static function isFileExcluded(string|false|null $fileName): bool $baseDir = Config::getProjectRoot(); - $longestIncludeMatch = 0; + self::$compiledIncludes = []; foreach ($includes as $pattern) { - if (! \is_string($pattern)) { - continue; - } - $regex = self::compileGlobToRegex($pattern, $baseDir); - if (preg_match($regex, $normalizedPath) === 1) { - $longestIncludeMatch = max($longestIncludeMatch, \strlen(trim($pattern))); + if (\is_string($pattern)) { + $trimmed = trim($pattern); + self::$compiledIncludes[] = [ + 'len' => \strlen($trimmed), + 'regex' => self::compileGlobToRegex($trimmed, $baseDir), + ]; } } - $longestExcludeMatch = 0; + self::$compiledExcludes = []; foreach ($excludes as $pattern) { - if (! \is_string($pattern)) { - continue; - } - $regex = self::compileGlobToRegex($pattern, $baseDir); - if (preg_match($regex, $normalizedPath) === 1) { - $longestExcludeMatch = max($longestExcludeMatch, \strlen(trim($pattern))); + if (\is_string($pattern)) { + $trimmed = trim($pattern); + self::$compiledExcludes[] = [ + 'len' => \strlen($trimmed), + 'regex' => self::compileGlobToRegex($trimmed, $baseDir), + ]; } } - - // Equal specificity tie-breaker: Exclude wins! - return $longestExcludeMatch >= $longestIncludeMatch; } /** @@ -89,4 +161,4 @@ private static function compileGlobToRegex(string $glob, string $baseDir): strin return '#' . $pattern . '#i'; } -} +} \ No newline at end of file diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 3a466c2..84d28e1 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -35,7 +35,7 @@ public static function checkParams( object|string|null $thisOrClass, TypeValidatorRegistry $registry ): ?ErrorMessage { - if (! (bool) (Config::get()['params'] ?? true)) { + if (! Config::isParamsEnabled()) { return null; } @@ -108,12 +108,25 @@ private static function resolveEffectiveFunction(string $function, object|string $traitAliases = HierarchyResolver::getTraitAliases($targetClass); if (\count($traitAliases) > 0) { - $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 $targetClass . '::' . $frameFunc; + $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 $targetClass . '::' . $frameFunc; + } } } } @@ -134,7 +147,7 @@ private static function handleMagicCall( TypeValidatorRegistry $registry ): ?ErrorMessage { $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); - if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) { + if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) { return null; } @@ -164,9 +177,9 @@ private static function handleMagicCall( */ private static function initializeCallContext(string $effectiveFunction, ?object $thisObj, array $templates): void { - if ($thisObj === null) { + if ($thisObj === null && \count($templates) > 0) { TemplateManager::clearCallBindings($effectiveFunction, $templates); - } elseif (str_contains($effectiveFunction, '::')) { + } elseif ($thisObj !== null && str_contains($effectiveFunction, '::')) { $declaringClass = explode('::', $effectiveFunction, 2)[0]; TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index dda9729..4e98162 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -37,7 +37,7 @@ public static function checkReturn( TypeValidatorRegistry $registry, callable $wrapIterableCallback ): mixed { - if (! (bool) (Config::get()['returns'] ?? true)) { + if (! Config::isReturnsEnabled()) { return $value; } @@ -98,12 +98,25 @@ private static function resolveEffectiveFunction(string $function, object|string $traitAliases = HierarchyResolver::getTraitAliases($targetClass); if (\count($traitAliases) > 0) { - $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 $targetClass . '::' . $frameFunc; + $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 $targetClass . '::' . $frameFunc; + } } } } @@ -126,7 +139,7 @@ private static function handleMagicReturn( callable $wrapIterableCallback ): mixed { $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); - if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) { + if (! $isMagicCall || ! Config::isMagicMethodsEnabled()) { return null; } diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 6513285..576fd9c 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -5,6 +5,7 @@ namespace TypePHP\Internal; use TypePHP\Contract\ContractParser; +use TypePHP\Contract\FileFilter; use TypePHP\Contract\HierarchyResolver; use TypePHP\Extension\ExtensionInterface; use TypePHP\Extension\ExtensionManager; @@ -22,11 +23,74 @@ final class Config */ private static ?array $cachedConfig = null; - /** - * Cached absolute project root path. - */ private static ?string $projectRoot = null; + private static bool $enabled = true; + + private static bool $params = true; + + private static bool $returns = true; + + private static bool $magicProperties = true; + + private static bool $magicMethods = true; + + private static bool $respectIgnoreTags = true; + + public static function isEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$enabled; + } + + public static function isParamsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$params; + } + + public static function isReturnsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$returns; + } + + public static function isMagicPropertiesEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$magicProperties; + } + + public static function isMagicMethodsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$magicMethods; + } + + public static function isRespectIgnoreTagsEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$respectIgnoreTags; + } + /** * 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. @@ -124,6 +188,8 @@ public static function get(): array /** @var array $mergedConfig */ $mergedConfig = array_replace_recursive($defaultConfig, $userConfig); + self::syncFlags($mergedConfig); + return self::$cachedConfig = $mergedConfig; } @@ -138,8 +204,10 @@ public static function set(array $config): void $mergedConfig = array_replace_recursive(self::get(), $config); self::$cachedConfig = $mergedConfig; + self::syncFlags($mergedConfig); ContractParser::reset(); + FileFilter::reset(); } /** @@ -149,9 +217,31 @@ public static function reset(): void { self::$cachedConfig = null; self::$projectRoot = null; + self::$enabled = true; + self::$params = true; + self::$returns = true; + self::$magicProperties = true; + self::$magicMethods = true; + self::$respectIgnoreTags = true; ContractParser::reset(); TemplateManager::reset(); HierarchyResolver::reset(); + FileFilter::reset(); + } + + /** + * Synchronizes cached static boolean flags for fast O(1) checking. + * + * @param array $config + */ + 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::$magicProperties = (bool) ($config['magic_properties'] ?? true); + self::$magicMethods = (bool) ($config['magic_methods'] ?? true); + self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); } -} +} \ No newline at end of file diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 9efcc34..17cb42b 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use TypePHP\Contract\ContractParser; use TypePHP\Internal\Checker\GeneratorChecker; use TypePHP\Internal\Checker\InlineChecker; use TypePHP\Internal\Checker\ParamChecker; @@ -27,7 +28,7 @@ final class RuntimeTypeChecker */ public static function isEnabled(): bool { - return (bool) (Config::get()['enabled'] ?? true); + return Config::isEnabled(); } /** @@ -35,7 +36,7 @@ public static function isEnabled(): bool */ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return null; } @@ -47,7 +48,7 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t */ public static function checkVariable(mixed $value, string $typeString, string $varName, string $file): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $value; } @@ -59,7 +60,7 @@ public static function checkVariable(mixed $value, string $typeString, string $v */ public static function checkProperty(mixed $value, mixed $objectOrClass, string $propName, string $file): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $value; } @@ -73,14 +74,15 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string */ public static function setupScope(string $function, array $vars, object|string|null $thisOrClass = null): ErrorMessage|ScopeCleaner|null { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return null; } $err = self::checkParams($function, $vars, $thisOrClass); + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + if ($err !== null) { - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; if ($thisObj === null) { TemplateManager::popCallFrame($function); } @@ -88,9 +90,14 @@ public static function setupScope(string $function, array $vars, object|string|n return $err; } - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + if ($thisObj !== null) { + return null; + } + + $contract = ContractParser::parse($function); + $hasTemplates = \count($contract['templates'] ?? []) > 0; - return $thisObj === null ? new ScopeCleaner($function) : null; + return $hasTemplates ? new ScopeCleaner($function) : null; } /** @@ -100,7 +107,7 @@ public static function setupScope(string $function, array $vars, object|string|n */ public static function checkParams(string $function, array $vars, object|string|null $thisOrClass = null): ?ErrorMessage { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return null; } @@ -114,7 +121,7 @@ public static function checkParams(string $function, array $vars, object|string| */ public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass = null, array $vars = []): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $value; } @@ -126,7 +133,7 @@ public static function checkReturn(string $function, mixed $value, object|string */ public static function checkSend(string $function, mixed $sendValue, object|string|null $thisOrClass = null): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $sendValue; } @@ -138,7 +145,7 @@ public static function checkSend(string $function, mixed $sendValue, object|stri */ public static function checkYield(string $function, mixed $key, mixed $value, object|string|null $thisOrClass = null): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $value; } @@ -150,7 +157,7 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob */ public static function wrapCallable(string $function, string $paramName, mixed $callable, object|string|null $thisOrClass = null): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $callable; } @@ -162,7 +169,7 @@ public static function wrapCallable(string $function, string $paramName, mixed $ */ public static function wrapIterable(string $function, string $paramName, mixed $iterable, object|string|null $thisOrClass = null): mixed { - if (! self::isEnabled()) { + if (! Config::isEnabled()) { return $iterable; } diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index bb3b5f9..d9a600b 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -38,6 +38,76 @@ */ final class SpecialTypeResolver { + /** + * Fast lookup set for built-in PHP and PHPDoc type keywords. + */ + private const BUILTIN_TYPE_KEYWORDS = [ + 'int' => true, + 'integer' => true, + 'string' => true, + 'float' => true, + 'double' => true, + 'bool' => true, + 'boolean' => true, + 'array' => true, + 'list' => true, + 'object' => true, + 'callable' => true, + 'iterable' => true, + 'resource' => true, + 'null' => true, + 'true' => true, + 'false' => true, + 'mixed' => true, + 'scalar' => true, + 'void' => true, + 'self' => true, + 'static' => true, + 'parent' => true, + '$this' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'class-string' => true, + 'interface-string' => true, + 'trait-string' => true, + 'enum-string' => true, + 'callable-string' => true, + 'numeric-string' => true, + 'non-empty-string' => true, + 'lowercase-string' => true, + 'non-empty-lowercase-string' => true, + 'uppercase-string' => true, + 'non-empty-uppercase-string' => true, + 'array-key' => true, + 'literal-string' => true, + 'truthy-string' => true, + 'non-empty-array' => true, + 'non-empty-list' => true, + 'number' => true, + 'numeric' => true, + 'truthy' => true, + 'falsy' => true, + 'falsey' => true, + 'min' => true, + 'max' => true, + '*' => true, + 'never' => true, + 'never-return' => true, + 'never-returns' => true, + 'no-return' => true, + 'open-resource' => true, + 'closed-resource' => true, + ]; + /** * In-memory cache of file import maps keyed by filename. * @@ -912,72 +982,7 @@ public static function resolveFqcnForFile(string $name, string $file): string */ private static function isBuiltInTypeKeyword(string $name): bool { - return \in_array(strtolower($name), [ - 'int', - 'integer', - 'string', - 'float', - 'double', - 'bool', - 'boolean', - 'array', - 'list', - 'object', - 'callable', - 'iterable', - 'resource', - 'null', - 'true', - 'false', - 'mixed', - 'scalar', - 'void', - 'self', - 'static', - 'parent', - '$this', - 'positive-int', - 'negative-int', - 'non-positive-int', - 'non-negative-int', - 'non-zero-int', - 'unsigned-int', - 'positive-float', - 'negative-float', - 'non-positive-float', - 'non-negative-float', - 'non-zero-float', - 'class-string', - 'interface-string', - 'trait-string', - 'enum-string', - 'callable-string', - 'numeric-string', - 'non-empty-string', - 'lowercase-string', - 'non-empty-lowercase-string', - 'uppercase-string', - 'non-empty-uppercase-string', - 'array-key', - 'literal-string', - 'truthy-string', - 'non-empty-array', - 'non-empty-list', - 'number', - 'numeric', - 'truthy', - 'falsy', - 'falsey', - 'min', - 'max', - '*', - 'never', - 'never-return', - 'never-returns', - 'no-return', - 'open-resource', - 'closed-resource', - ], true); + return isset(self::BUILTIN_TYPE_KEYWORDS[strtolower($name)]); } /** @@ -1056,4 +1061,4 @@ private static function parseFileMetadata(string $fileName, string $source): voi // Silently fall back to empty metadata if parsing fails } } -} +} \ No newline at end of file diff --git a/src/Validator/TypeValidatorRegistry.php b/src/Validator/TypeValidatorRegistry.php index f0e4f3c..d3d5056 100644 --- a/src/Validator/TypeValidatorRegistry.php +++ b/src/Validator/TypeValidatorRegistry.php @@ -33,6 +33,14 @@ final class TypeValidatorRegistry */ private static ?\WeakMap $validatedObjectCache = null; + /** + * Resets the validated object cache. Useful for test isolation. + */ + public static function reset(): void + { + self::$validatedObjectCache = null; + } + public function __construct() { $this->validators = [ @@ -53,10 +61,13 @@ 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 (\is_object($value)) { + if ($isObj) { self::$validatedObjectCache ??= new \WeakMap(); - $nodeKey = (string) $node; + $nodeKey = ($node instanceof IdentifierTypeNode) ? $node->name : (string) $node; if (isset(self::$validatedObjectCache[$value][$nodeKey])) { return null; @@ -70,12 +81,12 @@ public function validate(mixed $value, TypeNode $node, string $context): ?ErrorM $err = $validator->validate($value, $node, $context, $this); - if ($err === null && \is_object($value)) { + if ($err === null && $isObj && $nodeKey !== null) { $cache = self::$validatedObjectCache[$value] ?? []; - $cache[(string) $node] = true; + $cache[$nodeKey] = true; self::$validatedObjectCache[$value] = $cache; } return $err; } -} +} \ No newline at end of file