diff --git a/bootstrap.php b/bootstrap.php index 71e4bb1..a96f695 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -13,11 +13,16 @@ require_once __DIR__ . '/vendor/codeigniter4/framework/system/Test/bootstrap.php'; -$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/vendor/codeigniter4/framework/system/Helpers')); +foreach ([ + 'vendor/codeigniter4/framework/app/Config', + 'vendor/codeigniter4/framework/system/Helpers' +] as $directory) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); -/** @var SplFileInfo $helper */ -foreach ($iterator as $helper) { - if ($helper->isFile()) { - require_once $helper->getRealPath(); + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + require_once $file->getRealPath(); + } } } diff --git a/docs/type-inference.md b/docs/type-inference.md index 263bf4c..62f46f3 100644 --- a/docs/type-inference.md +++ b/docs/type-inference.md @@ -122,6 +122,42 @@ This also allows dynamic return type transformation of `CodeIgniter\Model` when ## Dynamic Static Method Return Type Extensions +### CacheFactoryHandlerReturnTypeExtension + +This extension provides precise return type to `CacheFactory::getHandler()` static method. + +**Before:** +```php +\PHPStan\dumpType(CacheFactory::getHandler(new Cache())); // CodeIgniter\Cache\CacheInterface +\PHPStan\dumpType(CacheFactory::getHandler(new Cache(), 'redis')); // CodeIgniter\Cache\CacheInterface +``` + +**After:** +```php +\PHPStan\dumpType(CacheFactory::getHandler(new Cache())); // CodeIgniter\Cache\Handlers\FileHandler +\PHPStan\dumpType(CacheFactory::getHandler(new Cache(), 'redis')); // CodeIgniter\Cache\Handlers\RedisHandler +``` + +> [!NOTE] +> **Configuration:** +> +> By default, this extension only considers the primary handler as the return type. If that fails (e.g. the handler +> is not defined in the Cache config's `$validHandlers` array), then this will return the backup handler as +> return type. If you want to return both primary and backup handlers as return type, you can set this: +> +> ```yml +> parameters: +> codeigniter: +> addBackupHandlerAsReturnType: true +> ``` +> +> This setting will give the return type as a benevolent union of the primary and backup handler types. +> +> ```php +> \PHPStan\dumpType(CacheFactory::getHandler(new Cache())); // (FileHandler|DummyHandler) +> \PHPStan\dumpType(CacheFactory::getHandler(new Cache(), 'redis', 'file')); // (FileHandler|RedisHandler) +> ``` + ### ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension This extension provides precise return type to `ReflectionHelper`'s static `getPrivateMethodInvoker()` method. diff --git a/extension.neon b/extension.neon index 9b74a1b..751d57d 100644 --- a/extension.neon +++ b/extension.neon @@ -10,6 +10,7 @@ parameters: - CodeIgniter\Config\ additionalModelNamespaces: [] additionalServices: [] + addBackupHandlerAsReturnType: false notStringFormattedFields: [] checkArgumentTypeOfFactories: true checkArgumentTypeOfConfig: true @@ -21,6 +22,7 @@ parametersSchema: additionalConfigNamespaces: listOf(string()) additionalModelNamespaces: listOf(string()) additionalServices: listOf(string()) + addBackupHandlerAsReturnType: bool() notStringFormattedFields: arrayOf(string()) checkArgumentTypeOfFactories: bool() checkArgumentTypeOfConfig: bool() @@ -78,6 +80,13 @@ services: - phpstan.broker.dynamicMethodReturnTypeExtension # DynamicStaticMethodReturnTypeExtension + - + class: CodeIgniter\PHPStan\Type\CacheFactoryGetHandlerReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension + arguments: + addBackupHandlerAsReturnType: %codeigniter.addBackupHandlerAsReturnType% + - class: CodeIgniter\PHPStan\Type\ReflectionHelperGetPrivateMethodInvokerReturnTypeExtension tags: diff --git a/src/Type/CacheFactoryGetHandlerReturnTypeExtension.php b/src/Type/CacheFactoryGetHandlerReturnTypeExtension.php new file mode 100644 index 0000000..6e8ff54 --- /dev/null +++ b/src/Type/CacheFactoryGetHandlerReturnTypeExtension.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\PHPStan\Type; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\Handlers\BaseHandler; +use Config\Cache; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Name; +use PHPStan\Analyser\Scope; +use PHPStan\Reflection\MethodReflection; +use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; +use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\TypeUtils; +use PHPStan\Type\UnionType; + +final class CacheFactoryGetHandlerReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension +{ + public function __construct( + private readonly bool $addBackupHandlerAsReturnType, + ) {} + + public function getClass(): string + { + return CacheFactory::class; + } + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'getHandler'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + $args = $methodCall->getArgs(); + + if ($args === []) { + return null; + } + + $cache = $this->getCache($args[0]->value, $scope); + + if ($cache === null || $cache->validHandlers === []) { + return new NeverType(true); + } + + $handlerType = $this->getHandlerType( + $args[1]->value ?? new ConstFetch(new Name('null')), + $scope, + $cache->validHandlers, + $cache->handler, + ); + $backupHandlerType = $this->getHandlerType( + $args[2]->value ?? new ConstFetch(new Name('null')), + $scope, + $cache->validHandlers, + $cache->backupHandler, + ); + + if (! $handlerType->isObject()->yes()) { + return $backupHandlerType; + } + + if (! $this->addBackupHandlerAsReturnType) { + return $handlerType; + } + + return TypeUtils::toBenevolentUnion(TypeCombinator::union($handlerType, $backupHandlerType)); + } + + private function getCache(Expr $expr, Scope $scope): ?Cache + { + foreach ($scope->getType($expr)->getObjectClassReflections() as $classReflection) { + if ($classReflection->getName() === Cache::class) { + $cache = $classReflection->getNativeReflection()->newInstance(); + + if ($cache instanceof Cache) { + return $cache; + } + } + } + + return null; + } + + /** + * @param array> $validHandlers + */ + private function getHandlerType(Expr $expr, Scope $scope, array $validHandlers, string $default): Type + { + return TypeTraverser::map( + $scope->getType($expr), + static function (Type $type, callable $traverse) use ($validHandlers, $default): Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type->isNull()->yes()) { + if (! isset($validHandlers[$default])) { + return new NeverType(true); + } + + return new ObjectType($validHandlers[$default]); + } + + $types = []; + + foreach ($type->getConstantStrings() as $constantString) { + $name = $constantString->getValue(); + + if (isset($validHandlers[$name])) { + $types[] = new ObjectType($validHandlers[$name]); + } else { + $types[] = new NeverType(true); + } + } + + if ($types === []) { + return new ObjectType(BaseHandler::class); + } + + return TypeCombinator::union(...$types); + }, + ); + } +} diff --git a/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php b/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php index bb37ced..a480707 100644 --- a/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php +++ b/tests/Type/DynamicStaticMethodReturnTypeExtensionTest.php @@ -37,6 +37,8 @@ public function testFileAsserts(string $assertType, string $file, mixed ...$args */ public static function provideFileAssertsCases(): iterable { + yield from self::gatherAssertTypes(__DIR__ . '/data/cache-factory.php'); + yield from self::gatherAssertTypes(__DIR__ . '/data/reflection-helper.php'); yield from self::gatherAssertTypes(__DIR__ . '/data/services-get-shared-instance.php'); diff --git a/tests/Type/data/cache-factory.php b/tests/Type/data/cache-factory.php new file mode 100644 index 0000000..b397396 --- /dev/null +++ b/tests/Type/data/cache-factory.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\PHPStan\Tests\Fixtures\Type; + +use CodeIgniter\Cache\CacheFactory; +use CodeIgniter\Cache\Handlers\BaseHandler; +use CodeIgniter\Cache\Handlers\DummyHandler; +use CodeIgniter\Cache\Handlers\FileHandler; +use CodeIgniter\Cache\Handlers\MemcachedHandler; +use CodeIgniter\Cache\Handlers\PredisHandler; +use CodeIgniter\Cache\Handlers\RedisHandler; +use CodeIgniter\Cache\Handlers\WincacheHandler; +use Config\Cache; + +use function PHPStan\Testing\assertType; + +$cache = new Cache(); +assertType(FileHandler::class, CacheFactory::getHandler($cache)); +assertType(FileHandler::class, CacheFactory::getHandler($cache, null)); + +assertType(DummyHandler::class, CacheFactory::getHandler($cache, 'dummy')); +assertType(FileHandler::class, CacheFactory::getHandler($cache, 'file')); +assertType(MemcachedHandler::class, CacheFactory::getHandler($cache, 'memcached')); +assertType(PredisHandler::class, CacheFactory::getHandler($cache, 'predis')); +assertType(RedisHandler::class, CacheFactory::getHandler($cache, 'redis')); +assertType(WincacheHandler::class, CacheFactory::getHandler($cache, 'wincache')); + +assertType(DummyHandler::class, CacheFactory::getHandler($cache, 'invalid')); +assertType('*NEVER*', CacheFactory::getHandler($cache, 'unknown', 'invalid')); + +assertType( + FileHandler::class . '|' . RedisHandler::class, + CacheFactory::getHandler($cache, (static fn (): string => mt_rand(0, 1) ? 'file' : 'redis')()), +); + +/** + * @param non-empty-string $name + */ +function getCache(string $name): void +{ + assertType(BaseHandler::class, CacheFactory::getHandler(new Cache(), $name)); +}