diff --git a/build/baseline-pre-8.0.neon b/build/baseline-pre-8.0.neon index 42c4ae6b539..fc97b50739f 100644 --- a/build/baseline-pre-8.0.neon +++ b/build/baseline-pre-8.0.neon @@ -24,6 +24,18 @@ parameters: count: 2 path: PHPStan/Build/TurboAttributeCollector.php + - + rawMessage: 'Call to an undefined method ReflectionClass::getAttributes().' + identifier: method.notFound + count: 1 + path: ../src/DependencyInjection/AutowiredServiceDiscoverer.php + + - + rawMessage: 'Call to an undefined method ReflectionParameter::getAttributes().' + identifier: method.notFound + count: 1 + path: ../src/DependencyInjection/AutowiredServiceDiscoverer.php + - rawMessage: 'Call to an undefined method ReflectionClass::isReadOnly().' identifier: method.notFound diff --git a/conf/config.neon b/conf/config.neon index 74db379e5b7..16029572b61 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -103,6 +103,7 @@ parameters: mixinExcludeClasses: [] scanFiles: [] scanDirectories: [] + autowiredServiceDirectories: [] parallel: jobSize: 20 processTimeout: 600.0 diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 953bab24371..b0f1ea3e03b 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -177,6 +177,7 @@ parametersSchema: mixinExcludeClasses: listOf(string()) scanFiles: listOf(string()) scanDirectories: listOf(string()) + autowiredServiceDirectories: listOf(string()) editorUrl: schema(string(), nullable()) editorUrlTitle: schema(string(), nullable()) errorFormat: schema(string(), nullable()) @@ -224,6 +225,7 @@ expandRelativePaths: - '[parameters][bootstrapFiles][]' - '[parameters][scanFiles][]' - '[parameters][scanDirectories][]' + - '[parameters][autowiredServiceDirectories][]' - '[parameters][tmpDir]' - '[parameters][pro][tmpDir]' - '[parameters][memoryLimitFile]' diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fef664d8b33..d31eae9709f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -213,7 +213,7 @@ parameters: - rawMessage: 'Call to static method expand() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass - count: 1 + count: 2 path: src/DependencyInjection/ContainerFactory.php - diff --git a/src/DependencyInjection/AutowiredAttributeServicesExtension.php b/src/DependencyInjection/AutowiredAttributeServicesExtension.php index 6f71bd6b86a..a3b4ef01aea 100644 --- a/src/DependencyInjection/AutowiredAttributeServicesExtension.php +++ b/src/DependencyInjection/AutowiredAttributeServicesExtension.php @@ -22,6 +22,7 @@ use ReflectionClass; use stdClass; use function array_key_exists; +use function array_merge; use function array_slice; use function count; use function explode; @@ -51,8 +52,12 @@ public function loadConfiguration(): void { require_once __DIR__ . '/../../vendor/attributes.php'; $builder = $this->getContainerBuilder(); + $discoverer = AutowiredServiceDiscoverer::createFromContainerBuilder($builder); - $autowiredParameters = Attributes::findTargetMethodParameters(AutowiredParameter::class); + $autowiredParameters = array_merge( + Attributes::findTargetMethodParameters(AutowiredParameter::class), + $discoverer->findTargetMethodParameters(AutowiredParameter::class), + ); $constructorParameters = []; foreach ($autowiredParameters as $parameter) { if (strcasecmp($parameter->method, '__construct') !== 0) { @@ -63,7 +68,9 @@ public function loadConfiguration(): void $constructorParameters[$lowerClass][] = $parameter; } - foreach (Attributes::findTargetClasses(AutowiredService::class) as $class) { + $interfaceTagMapping = ValidateServiceTagsExtension::getInterfaceTagMapping($builder); + + foreach (array_merge(Attributes::findTargetClasses(AutowiredService::class), $discoverer->findTargetClasses(AutowiredService::class)) as $class) { $reflection = new ReflectionClass($class->name); $attribute = $class->attribute; @@ -82,7 +89,7 @@ public function loadConfiguration(): void continue; } - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + foreach ($interfaceTagMapping as $interface => $tag) { if (!$reflection->implementsInterface($interface)) { continue; } @@ -91,7 +98,7 @@ public function loadConfiguration(): void } } - foreach (Attributes::findTargetClasses(NonAutowiredService::class) as $class) { + foreach (array_merge(Attributes::findTargetClasses(NonAutowiredService::class), $discoverer->findTargetClasses(NonAutowiredService::class)) as $class) { $attribute = $class->attribute; $definition = $builder->addDefinition($attribute->name) @@ -106,7 +113,7 @@ public function loadConfiguration(): void self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); } - foreach (Attributes::findTargetClasses(GenerateFactory::class) as $class) { + foreach (array_merge(Attributes::findTargetClasses(GenerateFactory::class), $discoverer->findTargetClasses(GenerateFactory::class)) as $class) { $attribute = $class->attribute; $definition = $builder->addFactoryDefinition(null) ->setImplement($attribute->interface); @@ -125,7 +132,7 @@ public function loadConfiguration(): void return; } - foreach (Attributes::findTargetClasses(RegisteredRule::class) as $class) { + foreach (array_merge(Attributes::findTargetClasses(RegisteredRule::class), $discoverer->findTargetClasses(RegisteredRule::class)) as $class) { $attribute = $class->attribute; if ($attribute->level > $config->level) { continue; @@ -139,7 +146,7 @@ public function loadConfiguration(): void self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); } - foreach (Attributes::findTargetClasses(RegisteredCollector::class) as $class) { + foreach (array_merge(Attributes::findTargetClasses(RegisteredCollector::class), $discoverer->findTargetClasses(RegisteredCollector::class)) as $class) { $attribute = $class->attribute; if ($attribute->level > $config->level) { continue; diff --git a/src/DependencyInjection/AutowiredExtensions.php b/src/DependencyInjection/AutowiredExtensions.php index 19f98785ffd..c381f01be85 100644 --- a/src/DependencyInjection/AutowiredExtensions.php +++ b/src/DependencyInjection/AutowiredExtensions.php @@ -12,6 +12,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredExtensionsExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_PARAMETER)] final class AutowiredExtensions diff --git a/src/DependencyInjection/AutowiredExtensionsExtension.php b/src/DependencyInjection/AutowiredExtensionsExtension.php index b4223364626..00855701fb5 100644 --- a/src/DependencyInjection/AutowiredExtensionsExtension.php +++ b/src/DependencyInjection/AutowiredExtensionsExtension.php @@ -12,6 +12,7 @@ use ReflectionMethod; use ReflectionNamedType; use function array_key_exists; +use function array_merge; use function is_string; use function sprintf; use function str_replace; @@ -40,7 +41,7 @@ public static function getCollectionServiceName(string $extensionInterfaceName): public function loadConfiguration(): void { $builder = $this->getContainerBuilder(); - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + foreach (ValidateServiceTagsExtension::getInterfaceTagMapping($builder) as $interface => $tag) { $builder->addDefinition(self::getCollectionServiceName($interface)) ->setType(LazyExtensionsCollection::class) ->setArgument('tagName', $tag) @@ -53,10 +54,14 @@ public function beforeCompile(): void { require_once __DIR__ . '/../../vendor/attributes.php'; $builder = $this->getContainerBuilder(); - $mapping = ValidateServiceTagsExtension::getInterfaceTagMapping(); + $mapping = ValidateServiceTagsExtension::getInterfaceTagMapping($builder); $parametersByClass = []; - foreach (Attributes::findTargetMethodParameters(AutowiredExtensions::class) as $parameter) { + $autowiredExtensions = array_merge( + Attributes::findTargetMethodParameters(AutowiredExtensions::class), + AutowiredServiceDiscoverer::createFromContainerBuilder($builder)->findTargetMethodParameters(AutowiredExtensions::class), + ); + foreach ($autowiredExtensions as $parameter) { if (strcasecmp($parameter->method, '__construct') !== 0) { throw new ShouldNotHappenException(sprintf('Attribute #[AutowiredExtensions] is only supported on constructor parameters, found on %s::%s() $%s.', $parameter->class, $parameter->method, $parameter->name)); } diff --git a/src/DependencyInjection/AutowiredParameter.php b/src/DependencyInjection/AutowiredParameter.php index d8547af2f86..405e85a37a5 100644 --- a/src/DependencyInjection/AutowiredParameter.php +++ b/src/DependencyInjection/AutowiredParameter.php @@ -12,6 +12,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_PARAMETER)] final class AutowiredParameter diff --git a/src/DependencyInjection/AutowiredService.php b/src/DependencyInjection/AutowiredService.php index 85778b23fa4..49440a94a52 100644 --- a/src/DependencyInjection/AutowiredService.php +++ b/src/DependencyInjection/AutowiredService.php @@ -13,6 +13,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class AutowiredService diff --git a/src/DependencyInjection/AutowiredServiceDiscoverer.php b/src/DependencyInjection/AutowiredServiceDiscoverer.php new file mode 100644 index 00000000000..54587fee95e --- /dev/null +++ b/src/DependencyInjection/AutowiredServiceDiscoverer.php @@ -0,0 +1,417 @@ + */ + private static array $instances = []; + + /** @var array> */ + private static array $files = []; + + /** @var list|null */ + private static ?array $attributeNamespaces = null; + + private bool $collected = false; + + /** @var array>> */ + private array $targetClasses = []; + + /** @var array>> */ + private array $targetMethodParameters = []; + + /** + * @param list $directories + */ + private function __construct(private string $key, private array $directories) + { + } + + /** + * @param list $directories + */ + public static function create(array $directories): self + { + $directories = self::normalizeDirectories($directories); + $key = implode("\n", $directories); + + return self::$instances[$key] ??= new self($key, $directories); + } + + /** + * Identifies the scanned set of directories. Callers memoizing anything derived from a + * discoverer key their caches on it, because two containers compiled in the same process + * can be configured with different directories. + */ + public function getKey(): string + { + return $this->key; + } + + public static function createFromContainerBuilder(ContainerBuilder $builder): self + { + /** @var list $directories */ + $directories = $builder->parameters['autowiredServiceDirectories']; + + return self::create($directories); + } + + /** + * Lists the files `create()` reads, without parsing any of them. ContainerFactory hashes them + * into the container cache key, so that editing a discovered class rebuilds the container. + * + * @param list $directories + * @return list + */ + public static function findFiles(array $directories): array + { + $directories = self::normalizeDirectories($directories); + $key = implode("\n", $directories); + if (array_key_exists($key, self::$files)) { + return self::$files[$key]; + } + + $files = []; + foreach ($directories as $directory) { + if (!is_dir($directory)) { + throw new ShouldNotHappenException(sprintf('Directory %s from the autowiredServiceDirectories parameter does not exist.', $directory)); + } + + $finder = new Finder(); + $finder->followLinks()->files()->name('*.php')->in($directory); + foreach ($finder as $fileInfo) { + $files[] = $fileInfo->getPathname(); + } + } + + $files = array_values(array_unique($files)); + sort($files); + + return self::$files[$key] = $files; + } + + /** + * @template T of object + * @param class-string $attribute + * @return list> + */ + public function findTargetClasses(string $attribute): array + { + $this->collect(); + + /** @var list> */ + return $this->targetClasses[$attribute] ?? []; + } + + /** + * @template T of object + * @param class-string $attribute + * @return list> + */ + public function findTargetMethodParameters(string $attribute): array + { + $this->collect(); + + /** @var list> */ + return $this->targetMethodParameters[$attribute] ?? []; + } + + /** + * Reads and parses the discovered files on the first lookup. Compiler extensions ask for + * attributes they do not always use - a container compiled without any of them never + * touches the filesystem beyond the file list ContainerFactory needs anyway. + */ + private function collect(): void + { + if ($this->collected) { + return; + } + + $this->collected = true; + + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodeFinder = new NodeFinder(); + $discoveredClasses = []; + + foreach (self::findFiles($this->directories) as $file) { + $contents = FileReader::read($file); + if (!self::mayContainAttributes($contents)) { + continue; + } + + try { + $stmts = $parser->parse($contents); + } catch (Error $e) { + throw new ShouldNotHappenException(sprintf('Cannot parse %s: %s', $file, $e->getMessage())); + } + if ($stmts === null) { + continue; + } + + $traverser = new NodeTraverser(new NameResolver()); + $stmts = $traverser->traverse($stmts); + + foreach ($nodeFinder->findInstanceOf($stmts, ClassLike::class) as $class) { + if ($class->namespacedName === null) { + // anonymous class + continue; + } + + $attributeNames = self::findAttributeNames($class); + if (count($attributeNames) === 0) { + continue; + } + + /** @var class-string $className */ + $className = $class->namespacedName->toString(); + foreach ($attributeNames as $attributeName) { + if (!in_array($attributeName, self::UNSUPPORTED_ATTRIBUTES, true)) { + continue; + } + + throw new ShouldNotHappenException(sprintf( + 'Attribute #[%s] on class %s is only supported for classes shipped with PHPStan itself, not for classes discovered through the autowiredServiceDirectories parameter.', + self::shortName($attributeName), + $className, + )); + } + + if (array_key_exists($className, $discoveredClasses)) { + // the same file can be reachable through two overlapping directories + continue; + } + $discoveredClasses[$className] = true; + + $this->collectClass($className, $file); + } + } + } + + /** + * @param class-string $className + */ + private function collectClass(string $className, string $file): void + { + if (!class_exists($className) && !interface_exists($className)) { + throw new ShouldNotHappenException(sprintf( + 'Class %s declared in %s has a PHPStan dependency injection attribute but cannot be autoloaded.', + $className, + $file, + )); + } + + $reflection = new ReflectionClass($className); + foreach (self::CLASS_ATTRIBUTES as $attributeClass) { + foreach ($reflection->getAttributes($attributeClass) as $attribute) { + $this->addTargetClass($attributeClass, $className, $attribute->newInstance()); + } + } + + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return; + } + + foreach ($constructor->getParameters() as $parameter) { + foreach (self::CONSTRUCTOR_PARAMETER_ATTRIBUTES as $attributeClass) { + foreach ($parameter->getAttributes($attributeClass) as $attribute) { + $this->addTargetMethodParameter($attributeClass, $className, $parameter->getName(), $attribute->newInstance()); + } + } + } + } + + /** + * @param class-string $attributeClass + * @param class-string $className + */ + private function addTargetClass(string $attributeClass, string $className, object $attribute): void + { + $this->targetClasses[$attributeClass][] = new TargetClass($attribute, $className); + } + + /** + * @param class-string $attributeClass + * @param class-string $className + * @param non-empty-string $parameterName + */ + private function addTargetMethodParameter(string $attributeClass, string $className, string $parameterName, object $attribute): void + { + $this->targetMethodParameters[$attributeClass][] = new TargetMethodParameter($attribute, $className, $parameterName, '__construct'); + } + + /** + * Names of PHPStan's dependency injection attributes on the class itself and on its + * constructor parameters. Everything else in the scanned directories is left alone. + * + * @return list + */ + private static function findAttributeNames(ClassLike $class): array + { + $known = array_merge(self::CLASS_ATTRIBUTES, self::CONSTRUCTOR_PARAMETER_ATTRIBUTES, self::UNSUPPORTED_ATTRIBUTES); + $attrGroups = $class->attrGroups; + $constructor = $class->getMethod('__construct'); + if ($constructor !== null) { + foreach ($constructor->params as $param) { + $attrGroups = array_merge($attrGroups, $param->attrGroups); + } + } + + $names = []; + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $name = $attr->name->toString(); + if (!in_array($name, $known, true)) { + continue; + } + + $names[] = $name; + } + } + + return array_values(array_unique($names)); + } + + /** + * Cheap pre-filter so that files without any of PHPStan's attributes are never parsed. + * + * Looking for the namespace the attributes live in rather than for each of their names keeps + * this down to a single needle per file, and covers every way of referring to them: a plain, + * grouped or aliased `use` line, a fully qualified name written in place, and - because the + * `namespace` declaration matches too - an unqualified name in a file that already sits in + * that namespace. + */ + private static function mayContainAttributes(string $contents): bool + { + if (!str_contains($contents, '#[')) { + return false; + } + + foreach (self::getAttributeNamespaces() as $namespace) { + if (str_contains($contents, $namespace)) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private static function getAttributeNamespaces(): array + { + if (self::$attributeNamespaces !== null) { + return self::$attributeNamespaces; + } + + $namespaces = []; + foreach (array_merge(self::CLASS_ATTRIBUTES, self::CONSTRUCTOR_PARAMETER_ATTRIBUTES, self::UNSUPPORTED_ATTRIBUTES) as $attributeClass) { + $position = strrpos($attributeClass, '\\'); + if ($position === false) { + continue; + } + + $namespaces[substr($attributeClass, 0, $position)] = true; + } + + return self::$attributeNamespaces = array_keys($namespaces); + } + + private static function shortName(string $className): string + { + $position = strrpos($className, '\\'); + if ($position === false) { + return $className; + } + + return substr($className, $position + 1); + } + + /** + * @param list $directories + * @return list + */ + private static function normalizeDirectories(array $directories): array + { + $directories = array_values(array_unique($directories)); + sort($directories); + + return $directories; + } + +} diff --git a/src/DependencyInjection/ConditionalTagsExtension.php b/src/DependencyInjection/ConditionalTagsExtension.php index be2589282ce..a3d9935a259 100644 --- a/src/DependencyInjection/ConditionalTagsExtension.php +++ b/src/DependencyInjection/ConditionalTagsExtension.php @@ -23,7 +23,7 @@ final class ConditionalTagsExtension extends CompilerExtension #[Override] public function getConfigSchema(): Nette\Schema\Schema { - $tags = array_values(ValidateServiceTagsExtension::getInterfaceTagMapping()); + $tags = array_values(ValidateServiceTagsExtension::getInterfaceTagMapping($this->getContainerBuilder())); return Expect::arrayOf(Expect::structure( array_fill_keys($tags, Expect::anyOf( diff --git a/src/DependencyInjection/Configurator.php b/src/DependencyInjection/Configurator.php index 49cc9f546df..7d37188ce0a 100644 --- a/src/DependencyInjection/Configurator.php +++ b/src/DependencyInjection/Configurator.php @@ -39,6 +39,9 @@ final class Configurator extends \Nette\Bootstrap\Configurator /** @var string[] */ private array $allConfigFiles = []; + /** @var string[] */ + private array $autowiredServiceFiles = []; + public function __construct(private LoaderFactory $loaderFactory, private bool $journalContainer) { parent::__construct(); @@ -58,6 +61,14 @@ public function setAllConfigFiles(array $allConfigFiles): void $this->allConfigFiles = $allConfigFiles; } + /** + * @param string[] $autowiredServiceFiles + */ + public function setAutowiredServiceFiles(array $autowiredServiceFiles): void + { + $this->autowiredServiceFiles = $autowiredServiceFiles; + } + /** * @return mixed[] */ @@ -103,6 +114,7 @@ public function loadContainer(): string is_file($attributesPhp) ? hash_file('sha256', $attributesPhp) : 'attributes-missing', NeonAdapter::CACHE_KEY, $this->getAllConfigFilesHashes(), + self::hashFiles($this->autowiredServiceFiles), ]; $className = $loader->load( @@ -232,9 +244,18 @@ public function createContainer(bool $initialize = true): OriginalNetteContainer * @return string[] */ private function getAllConfigFilesHashes(): array + { + return self::hashFiles($this->allConfigFiles); + } + + /** + * @param string[] $files + * @return string[] + */ + private static function hashFiles(array $files): array { $hashes = []; - foreach ($this->allConfigFiles as $file) { + foreach ($files as $file) { $hash = hash_file('sha256', $file); if ($hash === false) { diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index b2d2e6dc4cd..33e239a04e5 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -113,14 +113,14 @@ public function create( array $additionalParameters = [], ): Container { - [$allConfigFiles, $projectConfig] = $this->detectDuplicateIncludedFiles( - array_merge([__DIR__ . '/../../conf/parametersSchema.neon'], $additionalConfigFiles), - [ - 'rootDir' => $this->rootDirectory, - 'currentWorkingDirectory' => $this->currentWorkingDirectory, - 'env' => Environment::getCleanedArray(), - ], - ); + $loaderParameters = [ + 'rootDir' => $this->rootDirectory, + 'currentWorkingDirectory' => $this->currentWorkingDirectory, + 'env' => Environment::getCleanedArray(), + ]; + $configFiles = array_merge([__DIR__ . '/../../conf/parametersSchema.neon'], $additionalConfigFiles); + + [$allConfigFiles, $projectConfig] = $this->detectDuplicateIncludedFiles($configFiles, $loaderParameters, []); $configurator = new Configurator(new LoaderFactory( $this->fileHelper, @@ -161,6 +161,7 @@ public function create( } $configurator->setAllConfigFiles($allConfigFiles); + $configurator->setAutowiredServiceFiles($this->findAutowiredServiceFiles($configFiles, $loaderParameters, $projectConfig)); $container = $configurator->createContainer()->getByType(Container::class); $this->validateParameters($container->getParameters(), $projectConfig['parametersSchema']); @@ -229,18 +230,46 @@ public function getConfigDirectory(): string return $this->configDirectory; } + /** + * Files with classes registered through PHPStan's dependency injection attributes. + * The container is cached under a key derived from their contents, so that editing + * one of them rebuilds the container the same way editing a config file does. + * + * @param string[] $configFiles + * @param array $loaderParameters + * @param array $projectConfig + * @return list + */ + private function findAutowiredServiceFiles(array $configFiles, array $loaderParameters, array $projectConfig): array + { + if (count($projectConfig['parameters']['autowiredServiceDirectories'] ?? []) === 0) { + return []; + } + + // the first config pass runs without expandRelativePaths - the list is read from the very + // same config - so directories relative to the file declaring them are not absolutized yet + [, $expandedConfig] = $this->detectDuplicateIncludedFiles($configFiles, $loaderParameters, $projectConfig['expandRelativePaths']); + + /** @var list $directories */ + $directories = Helpers::expand($expandedConfig['parameters']['autowiredServiceDirectories'], $loaderParameters, true); + + return AutowiredServiceDiscoverer::findFiles($directories); + } + /** * @param string[] $configFiles * @param array $loaderParameters + * @param list $expandRelativePaths * @return array{list, array} * @throws DuplicateIncludedFilesException */ private function detectDuplicateIncludedFiles( array $configFiles, array $loaderParameters, + array $expandRelativePaths, ): array { - $neonAdapter = new NeonCachedFileReader([]); + $neonAdapter = new NeonCachedFileReader($expandRelativePaths); $phpAdapter = new PhpAdapter(); $allConfigFiles = []; $configArray = []; diff --git a/src/DependencyInjection/ExtensionInterface.php b/src/DependencyInjection/ExtensionInterface.php index 3558c77e610..1431a0fa6c2 100644 --- a/src/DependencyInjection/ExtensionInterface.php +++ b/src/DependencyInjection/ExtensionInterface.php @@ -13,6 +13,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class ExtensionInterface diff --git a/src/DependencyInjection/ExtensionsCollection.php b/src/DependencyInjection/ExtensionsCollection.php index 6b89074ff09..ad6152a0a68 100644 --- a/src/DependencyInjection/ExtensionsCollection.php +++ b/src/DependencyInjection/ExtensionsCollection.php @@ -15,6 +15,7 @@ * ) * ``` * + * @api * @template-covariant T of object */ interface ExtensionsCollection diff --git a/src/DependencyInjection/GenerateFactory.php b/src/DependencyInjection/GenerateFactory.php index c4b6a59ce03..dc488400b5b 100644 --- a/src/DependencyInjection/GenerateFactory.php +++ b/src/DependencyInjection/GenerateFactory.php @@ -15,6 +15,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class GenerateFactory diff --git a/src/DependencyInjection/NonAutowiredService.php b/src/DependencyInjection/NonAutowiredService.php index d9891975027..1fb61b0b9b9 100644 --- a/src/DependencyInjection/NonAutowiredService.php +++ b/src/DependencyInjection/NonAutowiredService.php @@ -9,6 +9,11 @@ * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class NonAutowiredService diff --git a/src/DependencyInjection/RegisteredCollector.php b/src/DependencyInjection/RegisteredCollector.php index 42af140bfcf..50762df3060 100644 --- a/src/DependencyInjection/RegisteredCollector.php +++ b/src/DependencyInjection/RegisteredCollector.php @@ -9,6 +9,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class RegisteredCollector diff --git a/src/DependencyInjection/RegisteredRule.php b/src/DependencyInjection/RegisteredRule.php index 3bb80348d50..5a75d3053a7 100644 --- a/src/DependencyInjection/RegisteredRule.php +++ b/src/DependencyInjection/RegisteredRule.php @@ -9,6 +9,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class RegisteredRule diff --git a/src/DependencyInjection/StubValidatorRuleServicesExtension.php b/src/DependencyInjection/StubValidatorRuleServicesExtension.php index f737d202dfd..555975b63b1 100644 --- a/src/DependencyInjection/StubValidatorRuleServicesExtension.php +++ b/src/DependencyInjection/StubValidatorRuleServicesExtension.php @@ -6,6 +6,7 @@ use olvlvl\ComposerAttributeCollector\Attributes; use Override; use PHPStan\PhpDoc\StubValidator; +use function array_merge; use function strcasecmp; use function strtolower; @@ -17,8 +18,12 @@ public function loadConfiguration(): void { require_once __DIR__ . '/../../vendor/attributes.php'; $builder = $this->getContainerBuilder(); + $discoverer = AutowiredServiceDiscoverer::createFromContainerBuilder($builder); - $autowiredParameters = Attributes::findTargetMethodParameters(AutowiredParameter::class); + $autowiredParameters = array_merge( + Attributes::findTargetMethodParameters(AutowiredParameter::class), + $discoverer->findTargetMethodParameters(AutowiredParameter::class), + ); $constructorParameters = []; foreach ($autowiredParameters as $parameter) { if (strcasecmp($parameter->method, '__construct') !== 0) { @@ -29,7 +34,7 @@ public function loadConfiguration(): void $constructorParameters[$lowerClass][] = $parameter; } - foreach (Attributes::findTargetClasses(ValidatesStubFiles::class) as $class) { + foreach (array_merge(Attributes::findTargetClasses(ValidatesStubFiles::class), $discoverer->findTargetClasses(ValidatesStubFiles::class)) as $class) { $definition = $builder->addDefinition(null) ->setFactory($class->name) ->setAutowired(false) diff --git a/src/DependencyInjection/ValidateServiceTagsExtension.php b/src/DependencyInjection/ValidateServiceTagsExtension.php index c01bdb6cd65..65f5fc4215f 100644 --- a/src/DependencyInjection/ValidateServiceTagsExtension.php +++ b/src/DependencyInjection/ValidateServiceTagsExtension.php @@ -3,6 +3,7 @@ namespace PHPStan\DependencyInjection; use Nette\DI\CompilerExtension; +use Nette\DI\ContainerBuilder; use Nette\PhpGenerator\ClassType; use olvlvl\ComposerAttributeCollector\Attributes; use Override; @@ -13,6 +14,7 @@ use function array_flip; use function array_key_exists; use function array_keys; +use function array_merge; use function count; use function sprintf; @@ -20,27 +22,34 @@ final class ValidateServiceTagsExtension extends CompilerExtension { - /** @var array|null */ - private static ?array $interfaceTagMapping = null; + /** @var array> */ + private static array $interfaceTagMapping = []; /** - * Derived from the #[ExtensionInterface] attribute above each extension interface. + * Derived from the #[ExtensionInterface] attribute above each extension interface, + * both PHPStan's own ones and those found in the autowiredServiceDirectories. * * @return array */ - public static function getInterfaceTagMapping(): array + public static function getInterfaceTagMapping(ContainerBuilder $builder): array { - if (self::$interfaceTagMapping !== null) { - return self::$interfaceTagMapping; - } - require_once __DIR__ . '/../../vendor/attributes.php'; + $discoverer = AutowiredServiceDiscoverer::createFromContainerBuilder($builder); + $cacheKey = $discoverer->getKey(); + if (array_key_exists($cacheKey, self::$interfaceTagMapping)) { + return self::$interfaceTagMapping[$cacheKey]; + } + $mapping = [ // vendor interface - cannot carry the #[ExtensionInterface] attribute NodeVisitor::class => RichParser::VISITOR_SERVICE_TAG, ]; - foreach (Attributes::findTargetClasses(ExtensionInterface::class) as $class) { + $classes = array_merge( + Attributes::findTargetClasses(ExtensionInterface::class), + $discoverer->findTargetClasses(ExtensionInterface::class), + ); + foreach ($classes as $class) { // the attribute is not repeatable but the collector does not validate that if (array_key_exists($class->name, $mapping)) { throw new ShouldNotHappenException(sprintf('Interface %s claims multiple tags', $class->name)); @@ -48,7 +57,7 @@ public static function getInterfaceTagMapping(): array $mapping[$class->name] = $class->attribute->tag; } - return self::$interfaceTagMapping = $mapping; + return self::$interfaceTagMapping[$cacheKey] = $mapping; } /** @@ -65,7 +74,7 @@ public static function getInterfaceTagMapping(): array public function afterCompile(ClassType $class): void { $builder = $this->getContainerBuilder(); - $mapping = self::getInterfaceTagMapping(); + $mapping = self::getInterfaceTagMapping($builder); $mappingCount = count($mapping); $flippedMapping = array_flip($mapping); diff --git a/src/DependencyInjection/ValidatesStubFiles.php b/src/DependencyInjection/ValidatesStubFiles.php index 086738e2cbd..d72586cba58 100644 --- a/src/DependencyInjection/ValidatesStubFiles.php +++ b/src/DependencyInjection/ValidatesStubFiles.php @@ -11,6 +11,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and StubValidatorRuleServicesExtension (similar to AutowiredAttributeServicesExtension). + * + * Extensions distributed outside phpstan-src list the directories to look for + * this attribute in through the `autowiredServiceDirectories` parameter. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class ValidatesStubFiles diff --git a/tests/PHPStan/DependencyInjection/AutowiredServiceDiscovererTest.php b/tests/PHPStan/DependencyInjection/AutowiredServiceDiscovererTest.php new file mode 100644 index 00000000000..522f8819c91 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AutowiredServiceDiscovererTest.php @@ -0,0 +1,161 @@ +create(self::$tmpDir, [__DIR__ . '/autowiredServices.neon'], []); + } + + public function testAutowiredService(): void + { + $service = self::createContainer()->getByType(TestedDiscoveredService::class); + $this->assertSame(self::$tmpDir, $service->getTmpDir()); + $this->assertFalse($service->isBleedingEdge()); + } + + public function testNonAutowiredService(): void + { + $container = self::createContainer(); + $this->assertInstanceOf( + TestedDiscoveredNonAutowiredService::class, + $container->getService('testedDiscoveredNonAutowiredService'), + ); + } + + /** + * The pre-filter deciding which files are worth parsing must not assume the attribute is + * referred to by name - an aliased namespace import spells out neither. + */ + public function testServiceUsingAnAliasedNamespaceImport(): void + { + $container = self::createContainer(); + $this->assertInstanceOf( + TestedDiscoveredAliasedService::class, + $container->getService('testedDiscoveredAliasedService'), + ); + } + + public function testNonAutowiredServiceCannotBeAutowired(): void + { + $container = self::createContainer(); + $this->expectException(MissingServiceException::class); + $container->getByType(TestedDiscoveredNonAutowiredService::class); + } + + public function testRegisteredRuleOnCurrentLevel(): void + { + $rules = self::createContainer()->getServicesByTag(LazyRegistry::RULE_TAG); + $this->assertCount(1, array_filter($rules, static fn ($rule): bool => $rule instanceof TestedDiscoveredRule)); + } + + public function testRegisteredRuleAboveCurrentLevelIsNotRegistered(): void + { + $rules = self::createContainer()->getServicesByTag(LazyRegistry::RULE_TAG); + $this->assertCount(0, array_filter($rules, static fn ($rule): bool => $rule instanceof TestedDiscoveredHighLevelRule)); + } + + public function testRegisteredCollector(): void + { + $collectors = self::createContainer()->getServicesByTag(RegistryFactory::COLLECTOR_TAG); + $this->assertCount(1, array_filter($collectors, static fn ($collector): bool => $collector instanceof TestedDiscoveredCollector)); + } + + public function testAutoTaggedExtension(): void + { + $extensions = self::createContainer()->getExtensionsCollection(ReadWritePropertiesExtension::class)->getAll(); + $this->assertCount(1, array_filter($extensions, static fn ($extension): bool => $extension instanceof TestedDiscoveredReadWritePropertiesExtension)); + } + + public function testDiscoveredExtensionInterface(): void + { + $extensions = self::createContainer()->getExtensionsCollection(DiscoveredExtension::class)->getAll(); + $this->assertCount(1, $extensions); + $this->assertInstanceOf(TestedDiscoveredExtension::class, $extensions[0]); + } + + /** + * Editing a discovered class has to invalidate the container the same way editing + * a configuration file does - otherwise a changed #[AutowiredService] would keep + * being served from the container cache. + */ + public function testEditingDiscoveredFileChangesTheContainer(): void + { + $dataDir = sys_get_temp_dir() . '/phpstan-discovered-service-' . md5(uniqid(more_entropy: true)); + $tmpDir = $dataDir . '/tmp'; + mkdir($dataDir . '/services', 0777, true); + mkdir($tmpDir, 0777, true); + + $serviceFile = $dataDir . '/services/DiscoveredService.php'; + FileWriter::write($serviceFile, "create($tmpDir, [$dataDir . '/config.neon'], []); + $this->assertTrue($container->hasService('discoveredTestService')); + + $containersBeforeEdit = self::listGeneratedContainers($tmpDir); + $this->assertCount(1, $containersBeforeEdit); + + FileWriter::write($serviceFile, FileReader::read($serviceFile) . "\n// edited\n"); + + (new ContainerFactory($dataDir))->create($tmpDir, [$dataDir . '/config.neon'], []); + + $containersAfterEdit = self::listGeneratedContainers($tmpDir); + $this->assertCount(2, $containersAfterEdit); + } + + /** + * @return list + */ + private static function listGeneratedContainers(string $tmpDir): array + { + $files = glob($tmpDir . '/cache/nette.configurator/Container_*.php'); + if ($files === false) { + return []; + } + + return $files; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AutowiredServices/DiscoveredExtension.php b/tests/PHPStan/DependencyInjection/AutowiredServices/DiscoveredExtension.php new file mode 100644 index 00000000000..59e8f46a85b --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AutowiredServices/DiscoveredExtension.php @@ -0,0 +1,13 @@ + + */ +#[RegisteredCollector(level: 0)] +final class TestedDiscoveredCollector implements Collector +{ + + public function getNodeType(): string + { + return Echo_::class; + } + + public function processNode(Node $node, Scope $scope) + { + return null; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredExtension.php b/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredExtension.php new file mode 100644 index 00000000000..11eacc41a3c --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredExtension.php @@ -0,0 +1,16 @@ + + */ +#[RegisteredRule(level: 9)] +final class TestedDiscoveredHighLevelRule implements Rule +{ + + public function getNodeType(): string + { + return Echo_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredNonAutowiredService.php b/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredNonAutowiredService.php new file mode 100644 index 00000000000..99cc9cd3a36 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredNonAutowiredService.php @@ -0,0 +1,11 @@ + + */ +#[RegisteredRule(level: 0)] +final class TestedDiscoveredRule implements Rule +{ + + public function getNodeType(): string + { + return Echo_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredService.php b/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredService.php new file mode 100644 index 00000000000..e7acf325e59 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredService.php @@ -0,0 +1,31 @@ +tmpDir; + } + + public function isBleedingEdge(): bool + { + return $this->bleedingEdge; + } + +} diff --git a/tests/PHPStan/DependencyInjection/autowiredServices.neon b/tests/PHPStan/DependencyInjection/autowiredServices.neon new file mode 100644 index 00000000000..bea535ee0f1 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/autowiredServices.neon @@ -0,0 +1,6 @@ +parameters: + autowiredServiceDirectories: + - AutowiredServices + +autowiredAttributeServices: + level: 0