From ae327538d09de430250887bed674c2c669995d56 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Tue, 25 Aug 2026 19:43:12 -0500 Subject: [PATCH] Register services from DI attributes found in configured directories PHPStan's dependency injection attributes were only usable from phpstan-src itself. They are collected at composer install time into vendor/attributes.php, which only ever covers phpstan-src's own src directory, and inside the PHAR the collector lives under a build-specific namespace prefix. Extensions shipped as separate packages therefore had no way in, and had to list every rule, type extension and collector by hand in a services: section. Add an autowiredServiceDirectories parameter. Each configuration file contributes its own directories, and because the parameter is a list the entries merge rather than overwrite, so one extension adding to it cannot hide another's classes. AutowiredServiceDiscoverer scans those directories, using the bundled parser only as a filter for classes carrying a known attribute, then reflects on the survivors to build the same TargetClass and TargetMethodParameter objects the collector produces. The compiler extensions merge both sources, so discovered classes go through the existing registration paths unchanged, including the auto-tagging derived from #[ExtensionInterface]. The scanned files feed the container cache key - otherwise editing a discovered rule would keep being served from the cached container. Parsing is deferred to the first attribute lookup, so a container that never asks does no work beyond listing the files, and the pre-filter matches the namespace the attributes live in rather than their names, which is both a single needle per file and immune to grouped or aliased use statements. getInterfaceTagMapping() now takes the ContainerBuilder, since the mapping depends on the configured directories and two containers in one process can disagree about them. Hoist its result out of the loop over autowired services in AutowiredAttributeServicesExtension: it used to be a free static memo, and calling it per service would otherwise re-derive the discoverer several hundred times per compile. #[ContainerExtension] is rejected with an explicit message. Nette snapshots its extension list before any of this runs, so compiler extensions still need an extensions: section. Mark the attributes extensions are now expected to reach for as @api. --- build/baseline-pre-8.0.neon | 12 + conf/config.neon | 1 + conf/parametersSchema.neon | 2 + phpstan-baseline.neon | 2 +- .../AutowiredAttributeServicesExtension.php | 21 +- .../AutowiredExtensions.php | 5 + .../AutowiredExtensionsExtension.php | 11 +- .../AutowiredParameter.php | 5 + src/DependencyInjection/AutowiredService.php | 5 + .../AutowiredServiceDiscoverer.php | 417 ++++++++++++++++++ .../ConditionalTagsExtension.php | 2 +- src/DependencyInjection/Configurator.php | 23 +- src/DependencyInjection/ContainerFactory.php | 47 +- .../ExtensionInterface.php | 5 + .../ExtensionsCollection.php | 1 + src/DependencyInjection/GenerateFactory.php | 5 + .../NonAutowiredService.php | 5 + .../RegisteredCollector.php | 5 + src/DependencyInjection/RegisteredRule.php | 5 + .../StubValidatorRuleServicesExtension.php | 9 +- .../ValidateServiceTagsExtension.php | 31 +- .../ValidatesStubFiles.php | 5 + .../AutowiredServiceDiscovererTest.php | 161 +++++++ .../AutowiredServices/DiscoveredExtension.php | 13 + .../TestedDiscoveredAliasedService.php | 15 + .../TestedDiscoveredCollector.php | 28 ++ .../TestedDiscoveredExtension.php | 16 + .../TestedDiscoveredHighLevelRule.php | 28 ++ .../TestedDiscoveredNonAutowiredService.php | 11 + ...DiscoveredReadWritePropertiesExtension.php | 28 ++ .../TestedDiscoveredRule.php | 28 ++ .../TestedDiscoveredService.php | 31 ++ .../autowiredServices.neon | 6 + 33 files changed, 954 insertions(+), 35 deletions(-) create mode 100644 src/DependencyInjection/AutowiredServiceDiscoverer.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServiceDiscovererTest.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/DiscoveredExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredAliasedService.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredCollector.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredHighLevelRule.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredNonAutowiredService.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredReadWritePropertiesExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredRule.php create mode 100644 tests/PHPStan/DependencyInjection/AutowiredServices/TestedDiscoveredService.php create mode 100644 tests/PHPStan/DependencyInjection/autowiredServices.neon 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