From a48647747a3aabca5a735887a78f79e1bda32e90 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 2 Nov 2026 20:30:17 +0000 Subject: [PATCH 01/14] Evaluate #[Conditional] parameter holding a list like conditionalTags A single `if` string that expands to a list of conditions, like strictRules.booleansInLoopConditions, now requires all of them to be met. It was wrapped in another list and the non-empty array counted as met. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../AttributeServicesRegistrar.php | 25 +++++++++++++------ .../AttributeServicesRegistrarTest.php | 5 ++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php index 0b668e44718..44a276b11c8 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -33,6 +33,7 @@ use function explode; use function implode; use function is_array; +use function is_string; use function preg_split; use function sprintf; use function strcasecmp; @@ -212,19 +213,29 @@ private static function findTargetClasses(AttributeTargetsProvider $targetsProvi */ public static function isConditionMet(ContainerBuilder $builder, string|array $if): bool { - $condition = []; - foreach ((array) $if as $item) { - $value = Neon::decode($item); - if ($value instanceof Entity) { - $value = new Statement($value->value, $value->attributes); - } + // a single string is passed on as it is - like in conditionalTags, it can expand to a list of conditions + if (is_string($if)) { + return ConditionalTagsExtension::isConditionMet(self::expandCondition($builder, $if)); + } - $condition[] = Helpers::expand($value, $builder->parameters, true); + $condition = []; + foreach ($if as $item) { + $condition[] = self::expandCondition($builder, $item); } return ConditionalTagsExtension::isConditionMet($condition); } + private static function expandCondition(ContainerBuilder $builder, string $condition): mixed + { + $value = Neon::decode($condition); + if ($value instanceof Entity) { + $value = new Statement($value->value, $value->attributes); + } + + return Helpers::expand($value, $builder->parameters, true); + } + /** * @param class-string $className * @param array>> $constructorParameters diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php index b6ac3670e1a..195eec32feb 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php @@ -28,6 +28,9 @@ public static function dataIsConditionMet(): iterable yield [['%disabled%', '%enabled%'], false]; yield [['%enabled%', 'not(%toggles.disabled%)'], true]; yield [['%enabled%', 'not(%toggles.enabled%)'], false]; + // like strictRules.booleansInLoopConditions: [%strictRules.allRules%, %featureToggles.bleedingEdge%] + yield ['%enabledList%', true]; + yield ['%mixedList%', false]; } /** @@ -53,6 +56,8 @@ private function createBuilder(): ContainerBuilder 'enabled' => true, 'disabled' => false, 'reference' => '%enabled%', + 'enabledList' => [true, '%enabled%'], + 'mixedList' => ['%enabled%', '%disabled%'], 'toggles' => [ 'enabled' => true, 'disabled' => false, From 91605d0b8e5709b7bd20c61a4ae753082857dd77 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 2 Nov 2026 20:54:23 +0000 Subject: [PATCH 02/14] Discover attribute services through the autoloader instead of Composer autoload rules Classes declared in the attributeServicesDirectories are loaded with class_exists(), so any autoloader works, including --autoload-file. Each class is autoloaded once, which avoids redeclaring classes whose file does not match its PSR-4 path. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../AttributeServicesDirectoriesResolver.php | 154 +--------- .../AttributeServicesDiscoverer.php | 276 ++++++++---------- .../AttributeServices/AutoloadRules.php | 45 --- .../AttributeServices/ComposerPackage.php | 1 - .../AttributeServices/ComposerProject.php | 19 +- .../ComposerProjectFactory.php | 101 +------ .../ResolvedAttributeServicesDirectory.php | 6 - ...tributeServicesDirectoriesResolverTest.php | 36 +-- .../AttributeServicesDiscovererTest.php | 78 +++++ .../data/prefilter/CommentOnly.php | 11 + .../psr4Mismatch/Moved/MismatchedClass.php | 12 + .../data/psr4Mismatch/WellPlaced.php | 11 + 12 files changed, 247 insertions(+), 503 deletions(-) delete mode 100644 src/DependencyInjection/AttributeServices/AutoloadRules.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/prefilter/CommentOnly.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/psr4Mismatch/Moved/MismatchedClass.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/psr4Mismatch/WellPlaced.php diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php b/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php index f8c66fe2ed1..3107294fc34 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php @@ -26,8 +26,8 @@ use const PHP_VERSION_ID; /** - * Validates the merged `attributeServicesDirectories` section against the analysed project's - * Composer metadata and computes each directory's contribution to the container cache key. + * Validates the merged `attributeServicesDirectories` section and computes each directory's + * contribution to the container cache key. * * Runs on every PHPStan run, before the container compiles - the result decides whether * a cached container can be reused, so it cannot sit behind the container cache itself. @@ -109,12 +109,7 @@ public function resolve($rawSectionValue): ResolvedAttributeServicesDirectories continue; } - $resolvedDirectory = $this->resolveDirectory($directory, $errors); - if ($resolvedDirectory === null) { - continue; - } - - $resolved[] = $resolvedDirectory; + $resolved[] = $this->resolveDirectory($directory, $errors); } if (count($errors) > 0) { @@ -127,50 +122,9 @@ public function resolve($rawSectionValue): ResolvedAttributeServicesDirectories /** * @param list $errors */ - private function resolveDirectory(string $directory, array &$errors): ?ResolvedAttributeServicesDirectory + private function resolveDirectory(string $directory, array &$errors): ResolvedAttributeServicesDirectory { - $ownership = $this->findOwnership($directory); - if ($ownership === null) { - $errors[] = sprintf('Directory %s from the attributeServicesDirectories section is not inside any project with Composer metadata known to PHPStan.', $directory); - return null; - } - - [$project, $package] = $ownership; - - if ($package !== null) { - // autoload-dev of a dependency is never installed by Composer - $rules = $package->autoload; - } elseif ($project->devInstalled) { - $rules = $project->rootAutoload->union($project->rootAutoloadDev); - } else { - $rules = $project->rootAutoload; - } - - $psr4 = []; - foreach ($rules->psr4 as $namespacePrefix => $baseDirectories) { - foreach ($baseDirectories as $baseDirectory) { - if (!$this->pathsIntersect($directory, $baseDirectory)) { - continue; - } - - $psr4[$namespacePrefix][] = $baseDirectory; - } - } - - $classmapPaths = []; - foreach ($rules->classmapPaths as $classmapPath) { - if (!$this->pathsIntersect($directory, $classmapPath)) { - continue; - } - - $classmapPaths[] = $classmapPath; - } - - if (count($psr4) === 0 && count($classmapPaths) === 0) { - $errors[] = $this->describeUncoveredDirectory($directory, $project, $package); - return null; - } - + $package = $this->findPackage($directory); if ($package !== null && $package->cacheToken !== null) { $cacheKeyComponent = [$directory => sprintf('package:%s:%s', $package->name, $package->cacheToken)]; } else { @@ -180,89 +134,20 @@ private function resolveDirectory(string $directory, array &$errors): ?ResolvedA return new ResolvedAttributeServicesDirectory( $directory, $package === null ? null : $package->name, - $psr4, - $classmapPaths, - $project->getAutoloadClassmapPath(), $cacheKeyComponent, ); } - private function describeUncoveredDirectory(string $directory, ComposerProject $project, ?ComposerPackage $package): string - { - $subject = $package === null - ? sprintf('the autoload section of %s/composer.json', $project->rootPath) - : sprintf('the autoload section of the Composer package %s', $package->name); - - if ($package === null) { - foreach ($this->collectPsrAndFilePaths($project->rootAutoloadDev) as $path) { - if (!$this->pathsIntersect($directory, $path)) { - continue; - } - - if (!$project->devInstalled) { - return sprintf( - 'Directory %s from the attributeServicesDirectories section is only covered by the autoload-dev section of %s/composer.json but Composer dependencies were installed with --no-dev.', - $directory, - $project->rootPath, - ); - } - } - } - - $unsupportedRules = $package === null - ? ($project->devInstalled ? $project->rootAutoload->union($project->rootAutoloadDev) : $project->rootAutoload) - : $package->autoload; - foreach ($unsupportedRules->psr0 as $baseDirectories) { - foreach ($baseDirectories as $baseDirectory) { - if (!$this->pathsIntersect($directory, $baseDirectory)) { - continue; - } - - return sprintf( - 'Directory %s from the attributeServicesDirectories section is only covered by a psr-0 autoload rule of %s. Only psr-4 and classmap rules are supported.', - $directory, - $subject, - ); - } - } - - return sprintf( - 'Directory %s from the attributeServicesDirectories section is not covered by %s. Add it to autoload.psr-4 or autoload.classmap in composer.json and run composer dump-autoload.', - $directory, - $subject, - ); - } - - /** - * @return array{ComposerProject, ComposerPackage|null}|null - */ - private function findOwnership(string $directory): ?array + private function findPackage(string $directory): ?ComposerPackage { foreach ($this->getComposerProjects() as $project) { $package = $project->findPackageOfDirectory($directory); if ($package !== null) { - return [$project, $package]; - } - } - - $containing = null; - foreach ($this->getComposerProjects() as $project) { - if (!$project->containsDirectory($directory)) { - continue; + return $package; } - - if ($containing !== null && strlen($containing->rootPath) >= strlen($project->rootPath)) { - continue; - } - - $containing = $project; } - if ($containing === null) { - return null; - } - - return [$containing, null]; + return null; } /** @@ -287,29 +172,6 @@ private function getComposerProjects(): array return $this->composerProjects = $projects; } - /** - * @return list - */ - private function collectPsrAndFilePaths(AutoloadRules $rules): array - { - $paths = []; - foreach ($rules->psr4 as $baseDirectories) { - foreach ($baseDirectories as $baseDirectory) { - $paths[] = $baseDirectory; - } - } - foreach ($rules->classmapPaths as $path) { - $paths[] = $path; - } - - return $paths; - } - - private function pathsIntersect(string $a, string $b): bool - { - return $a === $b || str_starts_with($a, $b . '/') || str_starts_with($b, $a . '/'); - } - /** * Content hashes of the PHP files under the directory that can carry PHPStan's DI attributes - * the cache-key fallback when a package version cannot stand in for the directory contents. diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php index aad07a25f81..f61f0889e65 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php @@ -16,6 +16,7 @@ use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\File\FileReader; use PHPStan\ShouldNotHappenException; +use PhpToken; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use ReflectionAttribute; @@ -31,32 +32,36 @@ use function function_exists; use function interface_exists; use function is_array; -use function is_file; use function is_string; -use function ksort; -use function preg_match; +use function realpath; use function sort; use function sprintf; use function str_contains; use function str_ends_with; use function str_starts_with; use function stripos; -use function strlen; use function strtolower; use function strtr; -use function substr; use function trait_exists; use function usort; +use const T_ATTRIBUTE; +use const T_CLASS; +use const T_INTERFACE; +use const T_NAME_QUALIFIED; +use const T_NAMESPACE; +use const T_STRING; +use const T_TRAIT; /** * Finds classes carrying PHPStan's DI attributes in the directories listed * in the `attributeServicesDirectories` section. * * Runs only while the container compiles (a container-cache hit never gets here). - * Class names come from Composer's autoload data - the PSR-4 path contract and - * vendor/composer/autoload_classmap.php - and the attributes are read with native - * runtime reflection; no file is ever parsed. A file that looks like it uses one of - * the attributes but whose class cannot be autoloaded is an explicit error. + * Class names are read from the tokens of the files that can use the attributes, the classes + * are loaded by the registered autoloaders - Composer's of any type, or the one from + * --autoload-file - and the attributes are read with native runtime reflection. + * A file that looks like it uses one of the attributes but whose class cannot be + * autoloaded from it is an explicit error. */ final class AttributeServicesDiscoverer { @@ -101,9 +106,6 @@ final class AttributeServicesDiscoverer 'ValidatesStubFiles', ]; - /** @var array>> autoload_classmap.php path => file => class names */ - private array $reverseClassmaps = []; - /** @var list */ private array $errors = []; @@ -118,13 +120,20 @@ public function discover(ResolvedAttributeServicesDirectories $resolvedDirectori $seenClasses = []; foreach ($resolvedDirectories->directories as $directory) { - foreach ($this->findCandidateClasses($directory) as $className => $file) { - if (array_key_exists(strtolower($className), $seenClasses)) { + foreach ($this->listPhpFiles($directory->directory) as $file) { + $contents = FileReader::read($file); + if (!self::isPrefilterPositive($contents)) { continue; } - $seenClasses[strtolower($className)] = true; - $this->collectClass($className, $file, $targetClasses, $targetMethodParameters); + foreach (self::findDeclaredClasses($contents) as $className) { + if (array_key_exists(strtolower($className), $seenClasses)) { + continue; + } + $seenClasses[strtolower($className)] = true; + + $this->collectClass($className, $file, $contents, $targetClasses, $targetMethodParameters); + } } } @@ -144,70 +153,16 @@ public function discover(ResolvedAttributeServicesDirectories $resolvedDirectori return new DiscoveredAttributeTargets($targetClasses, $targetMethodParameters); } - /** - * Candidate classes of one directory, derived from Composer's autoload data. Files that - * cannot yield an autoloadable class are only an error when their contents suggest one - * of PHPStan's DI attributes - anything else in the directory is none of our business. - * - * @return array class name => file - */ - private function findCandidateClasses(ResolvedAttributeServicesDirectory $directory): array - { - $candidates = []; - foreach ($this->listPhpFiles($directory->directory) as $file) { - $className = $this->derivePsr4ClassName($directory, $file); - if ($className !== null) { - $candidates[$className] = $file; - continue; - } - - $classmapClasses = $this->findClassmapClasses($directory, $file); - if ($classmapClasses !== null) { - if (count($classmapClasses) === 0) { - if (self::suggestsDiAttributes(FileReader::read($file))) { - $this->errors[] = sprintf( - 'File %s in a directory from the attributeServicesDirectories section is not present in Composer\'s class map. Run `composer dump-autoload` and try again.', - $file, - ); - } - continue; - } - - foreach ($classmapClasses as $classmapClass) { - $candidates[$classmapClass] = $file; - } - continue; - } - - if (!self::suggestsDiAttributes(FileReader::read($file))) { - continue; - } - - $this->errors[] = sprintf( - 'File %s in a directory from the attributeServicesDirectories section is not covered by the Composer autoload rules of the directory, so its class cannot be autoloaded.', - $file, - ); - } - - ksort($candidates); - - return $candidates; - } - /** * @param array>> $targetClasses * @param array>> $targetMethodParameters */ - private function collectClass(string $className, string $file, array &$targetClasses, array &$targetMethodParameters): void + private function collectClass(string $className, string $file, string $contents, array &$targetClasses, array &$targetMethodParameters): void { - if (!self::isPrefilterPositive(FileReader::read($file))) { - return; - } - if (!$this->classCanBeLoaded($className)) { - if (self::suggestsDiAttributes(FileReader::read($file))) { + if (self::suggestsDiAttributes($contents)) { $this->errors[] = sprintf( - 'Class %s expected in %s (through a directory from the attributeServicesDirectories section) cannot be autoloaded.', + 'Class %s declared in %s (through a directory from the attributeServicesDirectories section) cannot be autoloaded.', $className, $file, ); @@ -218,6 +173,20 @@ private function collectClass(string $className, string $file, array &$targetCla /** @var class-string $className */ $reflection = new ReflectionClass($className); + $loadedFile = $reflection->getFileName(); + if ($loadedFile === false || !self::isSameFile($loadedFile, $file)) { + // another declaration of the class, like in test data, is none of our business + if (self::suggestsDiAttributes($contents)) { + $this->errors[] = sprintf( + 'Class %s declared in %s (through a directory from the attributeServicesDirectories section) is autoloaded from %s instead.', + $className, + $file, + $loadedFile === false ? 'PHP itself' : $loadedFile, + ); + } + + return; + } foreach ($reflection->getAttributes() as $attribute) { $attributeClass = $this->resolveKnownAttribute($attribute->getName(), self::PUBLIC_CLASS_ATTRIBUTES); @@ -382,128 +351,108 @@ private function isPhpStanAttribute(string $attributeName): bool private function classCanBeLoaded(string $className): bool { try { - if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + // autoload only once - an autoloader includes the file again on each attempt + if (class_exists($className)) { return true; } - return function_exists('enum_exists') && enum_exists($className); + return interface_exists($className, false) + || trait_exists($className, false) + || (function_exists('enum_exists') && enum_exists($className, false)); } catch (Throwable) { return false; } } - /** - * @return list normalized with forward slashes, sorted - */ - private function listPhpFiles(string $directory): array + private static function isSameFile(string $a, string $b): bool { - $files = []; - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), - ); - foreach ($iterator as $fileInfo) { - if (!$fileInfo instanceof SplFileInfo || !$fileInfo->isFile()) { - continue; - } - if (!str_ends_with($fileInfo->getFilename(), '.php')) { - continue; - } - - $files[] = strtr($fileInfo->getPathname(), '\\', '/'); - } - - sort($files); + $realA = realpath($a); + $realB = realpath($b); - return $files; + return $realA !== false && $realA === $realB; } /** - * FQCN of the file per the PSR-4 path contract, or null when no PSR-4 rule of the - * directory covers the file (or a path segment cannot be a PHP name). + * Names of the classes, interfaces, traits and enums the file declares, none when the file + * has no attribute outside of comments and strings. Loading a class runs the code of its file, + * so classes of files that cannot carry the attributes, like rule test data, are not loaded. + * + * @return list */ - private function derivePsr4ClassName(ResolvedAttributeServicesDirectory $directory, string $file): ?string + private static function findDeclaredClasses(string $contents): array { - $bestBaseDirectory = null; - $bestPrefix = null; - foreach ($directory->psr4 as $namespacePrefix => $baseDirectories) { - foreach ($baseDirectories as $baseDirectory) { - if (!str_starts_with($file, $baseDirectory . '/')) { - continue; - } - if ($bestBaseDirectory !== null && strlen($baseDirectory) <= strlen($bestBaseDirectory)) { - continue; - } - - $bestBaseDirectory = $baseDirectory; - $bestPrefix = $namespacePrefix; + $tokens = PhpToken::tokenize($contents); + $count = count($tokens); + $namespace = ''; + $classes = []; + $hasAttribute = false; + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + if ($token->is(T_ATTRIBUTE)) { + $hasAttribute = true; + continue; + } + if ($token->is(T_NAMESPACE)) { + $next = self::findNextSignificantToken($tokens, $i); + $namespace = $next !== null && $next->is([T_STRING, T_NAME_QUALIFIED]) ? $next->text . '\\' : ''; + continue; } - } - if ($bestBaseDirectory === null || $bestPrefix === null) { - return null; - } + // T_ENUM does not exist on PHP 8.0 + if (!$token->is([T_CLASS, T_INTERFACE, T_TRAIT]) && $token->getTokenName() !== 'T_ENUM') { + continue; + } - $relativePath = substr($file, strlen($bestBaseDirectory) + 1, -4); - $segments = explode('/', $relativePath); - foreach ($segments as $segment) { - if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment) !== 1) { - return null; + // skips Foo::class and anonymous classes + $next = self::findNextSignificantToken($tokens, $i); + if ($next === null || !$next->is(T_STRING)) { + continue; } + + $classes[] = $namespace . $next->text; } - return $bestPrefix . strtr($relativePath, '/', '\\'); + return $hasAttribute ? $classes : []; } /** - * Class names Composer's class map records for the file, an empty list when the file - * sits under a classmap rule but the map does not know it (a stale dump), or null - * when no classmap rule of the directory covers the file. - * - * @return list|null + * @param array $tokens */ - private function findClassmapClasses(ResolvedAttributeServicesDirectory $directory, string $file): ?array + private static function findNextSignificantToken(array $tokens, int $i): ?PhpToken { - $covered = false; - foreach ($directory->classmapPaths as $classmapPath) { - if ($file === $classmapPath || str_starts_with($file, $classmapPath . '/')) { - $covered = true; - break; + $count = count($tokens); + for ($j = $i + 1; $j < $count; $j++) { + if (!$tokens[$j]->isIgnorable()) { + return $tokens[$j]; } } - if (!$covered) { - return null; - } - - $reverseClassmap = $this->getReverseClassmap($directory->autoloadClassmapPath); - - return $reverseClassmap[$file] ?? []; + return null; } /** - * @return array> file => class names + * @return list normalized with forward slashes, sorted */ - private function getReverseClassmap(string $autoloadClassmapPath): array + private function listPhpFiles(string $directory): array { - if (array_key_exists($autoloadClassmapPath, $this->reverseClassmaps)) { - return $this->reverseClassmaps[$autoloadClassmapPath]; - } - - $reverse = []; - if (is_file($autoloadClassmapPath)) { - $classmap = require $autoloadClassmapPath; - if (is_array($classmap)) { - foreach ($classmap as $className => $file) { - if (!is_string($className) || !is_string($file)) { - continue; - } - - $reverse[strtr($file, '\\', '/')][] = $className; - } + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + ); + foreach ($iterator as $fileInfo) { + if (!$fileInfo instanceof SplFileInfo || !$fileInfo->isFile()) { + continue; } + if (!str_ends_with($fileInfo->getFilename(), '.php')) { + continue; + } + + $files[] = strtr($fileInfo->getPathname(), '\\', '/'); } - return $this->reverseClassmaps[$autoloadClassmapPath] = $reverse; + sort($files); + + return $files; } /** @@ -517,13 +466,16 @@ public static function mayAffectDiscovery(string $contents): bool } /** - * Cheap gate before a class is autoloaded: any syntactically possible reference to one of - * PHPStan's attributes - a `use` import, a fully qualified name, an alias of either - - * contains the string "phpstan" case-insensitively, so a file without it cannot use them. + * Cheap gate before the file is tokenized: any syntactically possible reference to one of + * PHPStan's attributes - a `use` import, a group use, a fully qualified name, an alias of + * either - contains "phpstan" and "dependencyinjection" case-insensitively, so a file + * without them cannot use them. */ private static function isPrefilterPositive(string $contents): bool { - return str_contains($contents, '#[') && stripos($contents, 'phpstan') !== false; + return str_contains($contents, '#[') + && stripos($contents, 'phpstan') !== false + && stripos($contents, 'dependencyinjection') !== false; } /** diff --git a/src/DependencyInjection/AttributeServices/AutoloadRules.php b/src/DependencyInjection/AttributeServices/AutoloadRules.php deleted file mode 100644 index 1190e43f1d7..00000000000 --- a/src/DependencyInjection/AttributeServices/AutoloadRules.php +++ /dev/null @@ -1,45 +0,0 @@ -> $psr4 namespace prefix => base directories - * @param list $classmapPaths - * @param array> $psr0 - * @param list $files - */ - public function __construct( - public array $psr4, - public array $classmapPaths, - public array $psr0, - public array $files, - ) - { - } - - public static function createEmpty(): self - { - return new self([], [], [], []); - } - - public function union(self $other): self - { - return new self( - array_merge_recursive($this->psr4, $other->psr4), - array_merge($this->classmapPaths, $other->classmapPaths), - array_merge_recursive($this->psr0, $other->psr0), - array_merge($this->files, $other->files), - ); - } - -} diff --git a/src/DependencyInjection/AttributeServices/ComposerPackage.php b/src/DependencyInjection/AttributeServices/ComposerPackage.php index bbce4ce6ac4..787cc9660de 100644 --- a/src/DependencyInjection/AttributeServices/ComposerPackage.php +++ b/src/DependencyInjection/AttributeServices/ComposerPackage.php @@ -16,7 +16,6 @@ final class ComposerPackage public function __construct( public string $name, public ?string $cacheToken, - public AutoloadRules $autoload, ) { } diff --git a/src/DependencyInjection/AttributeServices/ComposerProject.php b/src/DependencyInjection/AttributeServices/ComposerProject.php index 7591c267b61..ece0e7bc1ec 100644 --- a/src/DependencyInjection/AttributeServices/ComposerProject.php +++ b/src/DependencyInjection/AttributeServices/ComposerProject.php @@ -5,23 +5,16 @@ use function str_starts_with; /** - * Composer metadata of one project the analysis runs against + * Installed Composer packages of one project the analysis runs against * (an entry of composerAutoloaderProjectPaths). */ final class ComposerProject { /** - * @param string $rootPath normalized with forward slashes, no trailing slash - * @param bool $devInstalled false when Composer ran with --no-dev * @param array $packagesByInstallPath install path => package, longest path first */ public function __construct( - public string $rootPath, - public string $vendorDirectory, - public bool $devInstalled, - public AutoloadRules $rootAutoload, - public AutoloadRules $rootAutoloadDev, public array $packagesByInstallPath, ) { @@ -38,14 +31,4 @@ public function findPackageOfDirectory(string $directory): ?ComposerPackage return null; } - public function containsDirectory(string $directory): bool - { - return $directory === $this->rootPath || str_starts_with($directory, $this->rootPath . '/'); - } - - public function getAutoloadClassmapPath(): string - { - return $this->vendorDirectory . '/composer/autoload_classmap.php'; - } - } diff --git a/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php index ee93d118fe9..fcca73ab650 100644 --- a/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php +++ b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php @@ -19,9 +19,9 @@ use function uksort; /** - * Builds the Composer metadata view AttributeServicesDirectoriesResolver reasons about: - * root and per-package autoload rules with absolutized paths, the --no-dev flag, - * and per-package cache tokens derived from installed.php versions. + * Finds the installed packages of a Composer project with cache tokens derived + * from installed.php versions, for the container cache key of + * AttributeServicesDirectoriesResolver. */ final class ComposerProjectFactory { @@ -37,7 +37,6 @@ public function create(string $projectPath): ?ComposerProject return null; } - $rootPath = rtrim($this->fileHelper->normalizePath($projectPath, '/'), '/'); $vendorDirectory = rtrim($this->fileHelper->normalizePath( ComposerHelper::getVendorDirFromComposerConfig($projectPath, $composer), '/', @@ -45,10 +44,8 @@ public function create(string $projectPath): ?ComposerProject $installedJson = $this->loadInstalledJson($vendorDirectory); $installedPackages = []; - $devInstalled = true; if ($installedJson !== null) { $installedPackages = $installedJson['packages'] ?? $installedJson; - $devInstalled = (bool) ($installedJson['dev'] ?? true); } $versions = $this->loadInstalledVersions($vendorDirectory); @@ -70,7 +67,6 @@ public function create(string $projectPath): ?ComposerProject $packagesByInstallPath[$installPath] = new ComposerPackage( $package['name'], $this->createCacheToken($package['name'], $installPath, $vendorDirectory, $versions), - $this->extractAutoloadRules($package, 'autoload', $installPath), ); } } @@ -78,14 +74,7 @@ public function create(string $projectPath): ?ComposerProject // Longest install path first, so a package nested under another package's directory matches first. uksort($packagesByInstallPath, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); - return new ComposerProject( - $rootPath, - $vendorDirectory, - $devInstalled, - $this->extractAutoloadRules($composer, 'autoload', $rootPath), - $this->extractAutoloadRules($composer, 'autoload-dev', $rootPath), - $packagesByInstallPath, - ); + return new ComposerProject($packagesByInstallPath); } /** @@ -174,86 +163,4 @@ private function createCacheToken(string $packageName, string $installPath, stri return null; } - /** - * @param array $package - */ - private function extractAutoloadRules(array $package, string $autoloadSection, string $basePath): AutoloadRules - { - $section = $package[$autoloadSection] ?? []; - if (!is_array($section)) { - return AutoloadRules::createEmpty(); - } - - return new AutoloadRules( - $this->extractPsrRules($section, 'psr-4', $basePath), - $this->extractPathList($section, 'classmap', $basePath), - $this->extractPsrRules($section, 'psr-0', $basePath), - $this->extractPathList($section, 'files', $basePath), - ); - } - - /** - * @param array $section - * @return array> - */ - private function extractPsrRules(array $section, string $key, string $basePath): array - { - $rules = $section[$key] ?? []; - if (!is_array($rules)) { - return []; - } - - $result = []; - foreach ($rules as $namespacePrefix => $paths) { - if (!is_string($namespacePrefix)) { - continue; - } - - $absolutePaths = []; - foreach (is_array($paths) ? $paths : [$paths] as $path) { - if (!is_string($path)) { - continue; - } - - $absolutePaths[] = $this->absolutizeRulePath($basePath, $path); - } - - if ($absolutePaths === []) { - continue; - } - - $result[$namespacePrefix] = $absolutePaths; - } - - return $result; - } - - /** - * @param array $section - * @return list - */ - private function extractPathList(array $section, string $key, string $basePath): array - { - $paths = $section[$key] ?? []; - if (!is_array($paths)) { - return []; - } - - $result = []; - foreach ($paths as $path) { - if (!is_string($path)) { - continue; - } - - $result[] = $this->absolutizeRulePath($basePath, $path); - } - - return $result; - } - - private function absolutizeRulePath(string $basePath, string $path): string - { - return rtrim($this->fileHelper->normalizePath($basePath . '/' . $path, '/'), '/'); - } - } diff --git a/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php index 0af89999861..2ab465c6ce1 100644 --- a/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php +++ b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php @@ -12,18 +12,12 @@ final class ResolvedAttributeServicesDirectory /** * @param string $directory normalized with forward slashes, no trailing slash * @param string|null $packageName owning Composer package, null for a directory of the project itself - * @param array> $psr4 namespace prefix => base directories intersecting the directory - * @param list $classmapPaths classmap rule paths intersecting the directory - * @param string $autoloadClassmapPath the owning project's vendor/composer/autoload_classmap.php * @param array $cacheKeyComponent the directory's contribution to the container cache key - * a package version token, or per-file content hashes */ public function __construct( public string $directory, public ?string $packageName, - public array $psr4, - public array $classmapPaths, - public string $autoloadClassmapPath, public array $cacheKeyComponent, ) { diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php index a52747ca94f..cb5c0dfc25d 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php @@ -87,15 +87,17 @@ public function testMissingDirectory(): void $resolver->resolve([__DIR__ . '/does-not-exist']); } - public function testDirectoryOutsideComposerProjects(): void + public function testDirectoryOutsideComposerProjectsIsHashed(): void { $resolver = new AttributeServicesDirectoriesResolver(new FileHelper(self::getRepoRoot()), [], 80000); - $this->expectException(InvalidAttributeServicesDirectoriesException::class); - $this->expectExceptionMessage('is not inside any project with Composer metadata known to PHPStan'); - $resolver->resolve([__DIR__]); + $resolved = $resolver->resolve([__DIR__]); + + $this->assertCount(1, $resolved->directories); + $this->assertNull($resolved->directories[0]->packageName); + $this->assertArrayHasKey(__DIR__ . '/data/psr4Mismatch/WellPlaced.php', $resolved->directories[0]->cacheKeyComponent); } - public function testProjectOwnPsr4Directory(): void + public function testProjectOwnDirectory(): void { $repoRoot = self::getRepoRoot(); $directory = $repoRoot . '/src/DependencyInjection/AttributeServices'; @@ -104,8 +106,6 @@ public function testProjectOwnPsr4Directory(): void $this->assertCount(1, $resolved->directories); $resolvedDirectory = $resolved->directories[0]; $this->assertNull($resolvedDirectory->packageName); - $this->assertArrayHasKey('PHPStan\\', $resolvedDirectory->psr4); - $this->assertContains($repoRoot . '/src', $resolvedDirectory->psr4['PHPStan\\']); // only files that can carry PHPStan's DI attributes are part of the key $expectedFile = $directory . '/AttributeServicesDiscoverer.php'; @@ -114,18 +114,6 @@ public function testProjectOwnPsr4Directory(): void $this->assertArrayNotHasKey($directory . '/AttributeServicesDirectoriesResolver.php', $resolvedDirectory->cacheKeyComponent); } - public function testProjectOwnClassmapDirectoryFromAutoloadDev(): void - { - $repoRoot = self::getRepoRoot(); - $resolved = $this->createResolver()->resolve([__DIR__]); - - $this->assertCount(1, $resolved->directories); - $resolvedDirectory = $resolved->directories[0]; - $this->assertNull($resolvedDirectory->packageName); - $this->assertContains($repoRoot . '/tests/PHPStan', $resolvedDirectory->classmapPaths); - $this->assertSame($repoRoot . '/vendor/composer/autoload_classmap.php', $resolvedDirectory->autoloadClassmapPath); - } - public function testVendorPackageDirectory(): void { $repoRoot = self::getRepoRoot(); @@ -140,14 +128,6 @@ public function testVendorPackageDirectory(): void $this->assertTrue(str_starts_with(array_values($resolvedDirectory->cacheKeyComponent)[0], 'package:nikic/php-parser:')); } - public function testUncoveredDirectory(): void - { - $resolver = $this->createResolver(); - $this->expectException(InvalidAttributeServicesDirectoriesException::class); - $this->expectExceptionMessage('is not covered by the autoload section of'); - $resolver->resolve([self::getRepoRoot() . '/bin']); - } - public function testNestedDirectoriesDeduplicated(): void { $repoRoot = self::getRepoRoot(); @@ -166,7 +146,7 @@ public function testAllErrorsAreCollected(): void try { $resolver->resolve([ __DIR__ . '/does-not-exist', - self::getRepoRoot() . '/bin', + '%rootDir%/src', ]); $this->fail('Expected InvalidAttributeServicesDirectoriesException.'); } catch (InvalidAttributeServicesDirectoriesException $e) { diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php new file mode 100644 index 00000000000..275cbff1ce5 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php @@ -0,0 +1,78 @@ += 8.0.0')] +class AttributeServicesDiscovererTest extends TestCase +{ + + /** + * Composer's PSR-4 loader includes the file of the class name derived from the path on every + * autoload, so a file declaring another class must not be autoloaded more than once. + */ + #[RunInSeparateProcess] + public function testFileDeclaringAnotherClassThanItsPath(): void + { + $targets = $this->discover('/data/psr4Mismatch', 'AttributeServicesFixtures\\Psr4Mismatch\\'); + + $this->assertSame( + [WellPlaced::class], + array_map(static fn (TargetClass $target): string => $target->name, $targets->targetClasses[AutowiredService::class] ?? []), + ); + } + + /** + * Only files that can reference PHPStan's DI attributes have their classes loaded. + */ + #[RunInSeparateProcess] + public function testFileMentioningAttributesOnlyInCommentIsNotLoaded(): void + { + $targets = $this->discover('/data/prefilter', 'AttributeServicesFixtures\\PHPStan\\Prefilter\\'); + + $this->assertSame([], $targets->targetClasses); + $this->assertFalse(defined('ATTRIBUTE_SERVICES_FIXTURE_COMMENT_ONLY_LOADED')); + } + + private function discover(string $relativeDirectory, string $prefix): DiscoveredAttributeTargets + { + $directory = strtr(__DIR__, '\\', '/') . $relativeDirectory; + $autoloader = static function (string $className) use ($directory, $prefix): void { + if (!str_starts_with($className, $prefix)) { + return; + } + + $file = $directory . '/' . strtr(substr($className, strlen($prefix)), '\\', '/') . '.php'; + if (!is_file($file)) { + return; + } + + include $file; + }; + spl_autoload_register($autoloader, prepend: true); + + try { + return (new AttributeServicesDiscoverer())->discover(new ResolvedAttributeServicesDirectories([ + new ResolvedAttributeServicesDirectory($directory, null, []), + ])); + } finally { + spl_autoload_unregister($autoloader); + } + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/prefilter/CommentOnly.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/prefilter/CommentOnly.php new file mode 100644 index 00000000000..b1a35f78545 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/prefilter/CommentOnly.php @@ -0,0 +1,11 @@ + Date: Mon, 2 Nov 2026 21:24:39 +0000 Subject: [PATCH 03/14] Fix attribute name in AutowiredParameter docblock Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- src/DependencyInjection/AutowiredParameter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DependencyInjection/AutowiredParameter.php b/src/DependencyInjection/AutowiredParameter.php index 5cd773fc20d..7d135081827 100644 --- a/src/DependencyInjection/AutowiredParameter.php +++ b/src/DependencyInjection/AutowiredParameter.php @@ -6,7 +6,7 @@ /** * Autowires constructor parameters in service classes using #[AutowiredService], - * #[GeneratedFactory], #[RegisteredRule] or #[RegisteredCollector] attributes. + * #[GenerateFactory], #[RegisteredRule] or #[RegisteredCollector] attributes. * * If ref is omitted, it looks for parameter of the same name. * From 878b9a80f1288b0bd007afc1f1dd5f6210556fa9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 2 Nov 2026 21:24:39 +0000 Subject: [PATCH 04/14] Add migrate:neon2attributes command Replaces the rules, services and conditionalTags entries of the project's non-vendor NEON config files with attributes on the service classes in attributeServicesDirectories. Entries the attributes cannot express stay in the config files and are reported with the reason. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- Makefile | 1 + bin/phpstan | 2 + build/phpstan.neon | 1 + phpstan-baseline.neon | 12 + src/Command/CommandHelper.php | 38 +- src/Command/Neon2Attributes/AttributeSpec.php | 22 + .../Neon2Attributes/AttributesPrinter.php | 31 + .../Neon2Attributes/ClassAttributes.php | 22 + .../Neon2Attributes/ClassReference.php | 15 + .../ComposerConfigIncludes.php | 70 ++ .../Neon2Attributes/ConvertedService.php | 22 + src/Command/Neon2Attributes/ImportScope.php | 120 +++ src/Command/Neon2Attributes/KeptService.php | 36 + .../Neon2AttributesMigrator.php | 785 ++++++++++++++++++ .../Neon2Attributes/Neon2AttributesResult.php | 45 + .../PhpClassAttributesWriter.php | 221 +++++ .../Neon2Attributes/ServiceDefinition.php | 28 + src/Command/Neon2AttributesCommand.php | 359 ++++++++ .../ComposerConfigIncludesTest.php | 41 + .../Neon2AttributesMigratorTest.php | 239 ++++++ .../composer-includes/included/composer.json | 11 + .../composer-includes/included/extension.neon | 1 + .../composer-includes/included/phpstan.neon | 2 + .../composer-includes/included/rules.neon | 1 + .../no-composer/extension.neon | 1 + .../composer-includes/no-composer/rules.neon | 1 + .../composer-includes/no-extra/composer.json | 3 + .../composer-includes/no-extra/extension.neon | 1 + .../composer-includes/no-extra/rules.neon | 1 + .../not-included/composer.json | 11 + .../not-included/extension.neon | 1 + .../not-included/phpstan.neon | 2 + .../composer-includes/not-included/rules.neon | 1 + .../data/arguments/AlreadyParameter.php | 18 + .../arguments/AlreadyParameter.php.expected | 21 + .../Neon2Attributes/data/arguments/Call.php | 12 + .../data/arguments/DifferentParameter.php | 17 + .../data/arguments/DottedReference.php | 14 + .../arguments/DottedReference.php.expected | 19 + .../Neon2Attributes/data/arguments/Entity.php | 14 + .../data/arguments/Entity.php.expected | 19 + .../data/arguments/Inherited.php | 8 + .../data/arguments/InlineArray.php | 17 + .../Neon2Attributes/data/arguments/Named.php | 18 + .../data/arguments/Named.php.expected | 27 + .../data/arguments/NonString.php | 12 + .../data/arguments/OnlyNamed.php | 9 + .../data/arguments/OnlyNamed.php.expected | 12 + .../data/arguments/ParentWithConstructor.php | 12 + .../data/arguments/Positional.php | 17 + .../data/arguments/Positional.php.expected | 21 + .../data/arguments/config.neon | 46 + .../data/arguments/config.neon.expected | 21 + .../data/autowired/AsOneType.php | 9 + .../data/autowired/AsOneType.php.expected | 12 + .../data/autowired/AsTypes.php | 9 + .../data/autowired/AsTypes.php.expected | 12 + .../data/autowired/NotAutowired.php | 9 + .../data/autowired/NotAutowired.php.expected | 12 + .../data/autowired/NotAutowiredTagged.php | 26 + .../autowired/NotAutowiredWithoutName.php | 9 + .../data/autowired/OtherInterface.php | 8 + .../data/autowired/SomeInterface.php | 8 + .../data/autowired/config.neon | 20 + .../data/autowired/config.neon.expected | 9 + .../ConditionalExtension.php | 32 + .../ConditionalExtension.php.expected | 37 + .../conditional-php80/ConditionalRule.php | 25 + .../ConditionalRule.php.expected | 28 + .../conditional-php80/DifferentConditions.php | 25 + .../data/conditional-php80/ListRule.php | 25 + .../conditional-php80/ListRule.php.expected | 28 + .../data/conditional-php80/NegatedRule.php | 25 + .../NegatedRule.php.expected | 28 + .../RuleSectionConditional.php | 25 + .../TaggedAndConditional.php | 25 + .../data/conditional-php80/config.neon | 39 + .../conditional-php80/config.neon.expected | 21 + .../ConditionalExtension.php | 32 + .../ConditionalExtension.php.expected | 37 + .../conditional-php81/ConditionalRule.php | 25 + .../ConditionalRule.php.expected | 28 + .../conditional-php81/DifferentConditions.php | 25 + .../data/conditional-php81/ListRule.php | 25 + .../conditional-php81/ListRule.php.expected | 28 + .../data/conditional-php81/NegatedRule.php | 25 + .../NegatedRule.php.expected | 28 + .../RuleSectionConditional.php | 25 + .../TaggedAndConditional.php | 25 + .../data/conditional-php81/config.neon | 39 + .../conditional-php81/config.neon.expected | 21 + .../data/duplicates/InServicesAndRules.php | 25 + .../data/duplicates/InTwoFiles.php | 9 + .../data/duplicates/TwiceInServices.php | 9 + .../data/duplicates/config.neon | 13 + .../data/duplicates/other.neon | 2 + .../Neon2Attributes/data/factory/Aliased.php | 8 + .../data/factory/ArrayCreated.php | 8 + .../data/factory/ArrayCreated.php.expected | 11 + .../Neon2Attributes/data/factory/Created.php | 9 + .../data/factory/Created.php.expected | 12 + .../data/factory/CreatedFactory.php | 18 + .../data/factory/CreatedFactory.php.expected | 21 + .../data/factory/CreatedInterface.php | 8 + .../data/factory/Implementation.php | 8 + .../data/factory/ParameterCreated.php | 8 + .../data/factory/SameClass.php | 8 + .../data/factory/SameClass.php.expected | 11 + .../data/factory/SameClassWithArguments.php | 14 + .../SameClassWithArguments.php.expected | 19 + .../data/factory/StaticallyCreated.php | 9 + .../data/factory/TypedInterface.php | 8 + .../data/factory/TypedInterface.php.expected | 11 + .../Neon2Attributes/data/factory/config.neon | 30 + .../data/factory/config.neon.expected | 13 + .../data/formatting/AlsoKept.php | 12 + .../data/formatting/Converted.php | 9 + .../data/formatting/Converted.php.expected | 12 + .../Neon2Attributes/data/formatting/Kept.php | 12 + .../data/formatting/config.neon | 25 + .../data/formatting/config.neon.expected | 21 + .../data/generate-factory/Value.php | 12 + .../data/generate-factory/Value.php.expected | 15 + .../data/generate-factory/ValueFactory.php | 10 + .../generate-factory/ValueWithArguments.php | 15 + .../ValueWithArguments.php.expected | 20 + .../ValueWithArgumentsFactory.php | 10 + .../data/generate-factory/config.neon | 7 + .../generate-factory/config.neon.expected | 0 .../data/imports/AutowiredService.php | 8 + .../data/imports/ConflictingImport.php | 8 + .../imports/ConflictingImport.php.expected | 9 + .../data/imports/ExistingImport.php | 15 + .../data/imports/ExistingImport.php.expected | 16 + .../data/imports/SortedImports.php | 16 + .../data/imports/SortedImports.php.expected | 18 + .../data/imports/WithDocComment.php | 19 + .../data/imports/WithDocComment.php.expected | 21 + .../Neon2Attributes/data/imports/config.neon | 5 + .../data/imports/config.neon.expected | 0 .../data/misc/AutowiredSelf.php | 8 + .../data/misc/AutowiredSelf.php.expected | 11 + .../data/misc/FactoryEntity.php | 12 + .../data/misc/FactoryEntity.php.expected | 16 + .../Neon2Attributes/data/misc/FactoryKey.php | 8 + .../data/misc/FactoryKey.php.expected | 11 + .../Neon2Attributes/data/misc/TypeEntry.php | 8 + .../data/misc/TypeEntry.php.expected | 11 + .../Neon2Attributes/data/misc/config.neon | 15 + .../data/misc/config.neon.expected | 7 + .../data/named-service/Named.php | 9 + .../data/named-service/Named.php.expected | 12 + .../data/named-service/NamedString.php | 9 + .../named-service/NamedString.php.expected | 12 + .../data/named-service/config.neon | 4 + .../data/named-service/config.neon.expected | 0 .../Inside/InsideService.php | 9 + .../Inside/InsideService.php.expected | 12 + .../outside-directories/OutsideService.php | 9 + .../outside-directories/OutsideStatic.php | 13 + .../data/outside-directories/config.neon | 9 + .../outside-directories/config.neon.expected | 8 + .../data/overrides/JunitFormatter.php | 8 + .../Neon2Attributes/data/overrides/Own.php | 8 + .../data/overrides/Own.php.expected | 11 + .../data/overrides/config.neon | 5 + .../data/overrides/config.neon.expected | 3 + .../data/plain-service/ClassEntry.php | 9 + .../plain-service/ClassEntry.php.expected | 12 + .../data/plain-service/StringEntry.php | 9 + .../plain-service/StringEntry.php.expected | 12 + .../data/plain-service/config.neon | 4 + .../data/plain-service/config.neon.expected | 0 .../data/rules-section/MyRule.php | 25 + .../data/rules-section/MyRule.php.expected | 27 + .../data/rules-section/config.neon | 5 + .../data/rules-section/config.neon.expected | 2 + .../data/tags/PartiallyTaggedExtension.php | 31 + .../data/tags/TaggedExtension.php | 26 + .../data/tags/TaggedExtension.php.expected | 28 + .../Neon2Attributes/data/tags/TaggedRule.php | 25 + .../data/tags/TaggedRule.php.expected | 27 + .../Neon2Attributes/data/tags/UnknownTag.php | 9 + .../data/tags/UntaggedExtension.php | 26 + .../data/tags/UntaggedExtension.php.expected | 28 + .../Neon2Attributes/data/tags/config.neon | 18 + .../data/tags/config.neon.expected | 9 + .../data/unsupported-keys/AbstractService.php | 8 + .../unsupported-keys/AlreadyAttributed.php | 12 + .../data/unsupported-keys/TagsWithValues.php | 25 + .../data/unsupported-keys/WithSetup.php | 12 + .../data/unsupported-keys/config.neon | 14 + 192 files changed, 4613 insertions(+), 15 deletions(-) create mode 100644 src/Command/Neon2Attributes/AttributeSpec.php create mode 100644 src/Command/Neon2Attributes/AttributesPrinter.php create mode 100644 src/Command/Neon2Attributes/ClassAttributes.php create mode 100644 src/Command/Neon2Attributes/ClassReference.php create mode 100644 src/Command/Neon2Attributes/ComposerConfigIncludes.php create mode 100644 src/Command/Neon2Attributes/ConvertedService.php create mode 100644 src/Command/Neon2Attributes/ImportScope.php create mode 100644 src/Command/Neon2Attributes/KeptService.php create mode 100644 src/Command/Neon2Attributes/Neon2AttributesMigrator.php create mode 100644 src/Command/Neon2Attributes/Neon2AttributesResult.php create mode 100644 src/Command/Neon2Attributes/PhpClassAttributesWriter.php create mode 100644 src/Command/Neon2Attributes/ServiceDefinition.php create mode 100644 src/Command/Neon2AttributesCommand.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/ComposerConfigIncludesTest.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/included/composer.json create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/included/extension.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/included/phpstan.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/included/rules.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/extension.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/rules.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/composer.json create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/extension.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/rules.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/composer.json create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/extension.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/phpstan.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/rules.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/AlreadyParameter.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/AlreadyParameter.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Call.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/DifferentParameter.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/DottedReference.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/DottedReference.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Entity.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Entity.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Inherited.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/InlineArray.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Named.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Named.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/NonString.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/OnlyNamed.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/OnlyNamed.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/ParentWithConstructor.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Positional.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/Positional.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/arguments/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/AsOneType.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/AsOneType.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/AsTypes.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/AsTypes.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/NotAutowired.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/NotAutowired.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/NotAutowiredTagged.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/NotAutowiredWithoutName.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/OtherInterface.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/SomeInterface.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/autowired/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalExtension.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/DifferentConditions.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/RuleSectionConditional.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/TaggedAndConditional.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalExtension.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/DifferentConditions.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/RuleSectionConditional.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/TaggedAndConditional.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/duplicates/InServicesAndRules.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/duplicates/InTwoFiles.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/duplicates/TwiceInServices.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/duplicates/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/duplicates/other.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/Aliased.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/ArrayCreated.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/ArrayCreated.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/Created.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/Created.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/CreatedFactory.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/CreatedFactory.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/CreatedInterface.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/Implementation.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/ParameterCreated.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/SameClass.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/SameClass.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/SameClassWithArguments.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/SameClassWithArguments.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/StaticallyCreated.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/TypedInterface.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/TypedInterface.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/formatting/AlsoKept.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/formatting/Converted.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/formatting/Converted.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/formatting/Kept.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/formatting/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/formatting/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/Value.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/Value.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/ValueFactory.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/ValueWithArguments.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/ValueWithArguments.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/ValueWithArgumentsFactory.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/generate-factory/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/AutowiredService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/ConflictingImport.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/ConflictingImport.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/ExistingImport.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/ExistingImport.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/SortedImports.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/SortedImports.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/WithDocComment.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/WithDocComment.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/imports/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/AutowiredSelf.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/AutowiredSelf.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/FactoryEntity.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/FactoryEntity.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/FactoryKey.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/FactoryKey.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/TypeEntry.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/TypeEntry.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/misc/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/named-service/Named.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/named-service/Named.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/named-service/NamedString.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/named-service/NamedString.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/named-service/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/named-service/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/outside-directories/Inside/InsideService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/outside-directories/Inside/InsideService.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/outside-directories/OutsideService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/outside-directories/OutsideStatic.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/outside-directories/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/outside-directories/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/overrides/JunitFormatter.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/overrides/Own.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/overrides/Own.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/overrides/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/overrides/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/plain-service/ClassEntry.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/plain-service/ClassEntry.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/plain-service/StringEntry.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/plain-service/StringEntry.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/plain-service/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/plain-service/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/rules-section/MyRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/rules-section/MyRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/PartiallyTaggedExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedExtension.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedRule.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedRule.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/UnknownTag.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/UntaggedExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/UntaggedExtension.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/tags/config.neon.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/AbstractService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/AlreadyAttributed.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/TagsWithValues.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/WithSetup.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/config.neon diff --git a/Makefile b/Makefile index 9fc4d04913a..05614c1f875 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ lint: --exclude tests/PHPStan/Rules/Functions/data/arrow-function-nullsafe-by-ref.php \ --exclude tests/PHPStan/Levels/data/namedArguments.php \ --exclude tests/PHPStan/DependencyInjection/AttributeServices/data \ + --exclude tests/PHPStan/Command/Neon2Attributes/data \ --exclude tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/symbol-finder/class-name-before-global-constant.php \ --exclude tests/PHPStan/Rules/Keywords/data/continue-break.php \ --exclude tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php \ diff --git a/bin/phpstan b/bin/phpstan index 85fd6e79bc2..3e8cc8e3037 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -7,6 +7,7 @@ use PHPStan\Command\ClearResultCacheCommand; use PHPStan\Command\DiagnoseCommand; use PHPStan\Command\DumpParametersCommand; use PHPStan\Command\FixerWorkerCommand; +use PHPStan\Command\Neon2AttributesCommand; use PHPStan\Command\ResultCacheInfoCommand; use PHPStan\Command\WorkerCommand; use PHPStan\Internal\ComposerHelper; @@ -144,5 +145,6 @@ use Symfony\Component\Console\Helper\ProgressBar; $application->add(new FixerWorkerCommand($reversedComposerAutoloaderProjectPaths)); $application->add(new DumpParametersCommand($reversedComposerAutoloaderProjectPaths)); $application->add(new DiagnoseCommand($reversedComposerAutoloaderProjectPaths)); + $application->add(new Neon2AttributesCommand($reversedComposerAutoloaderProjectPaths)); $application->run(); })(); diff --git a/build/phpstan.neon b/build/phpstan.neon index 44b3deae353..b2686a5e811 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -198,6 +198,7 @@ parameters: paths: - ../src/Neon/* - ../src/Analyser/Ignore/IgnoreErrorsConfigFixer.php + - ../src/Command/Neon2Attributes/Neon2AttributesMigrator.php - ../tests/PHPStan/Neon/* - rawMessage: 'Property PHPStan\Command\CommandHelper::$reservedMemory is never read' # intentional diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 311f356272b..65597d8ada8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -235,6 +235,18 @@ parameters: count: 1 path: src/Command/ErrorsConsoleStyle.php + - + rawMessage: 'Call to method __construct() of internal class PhpParser\Internal\TokenStream from outside its root namespace PhpParser.' + identifier: method.internalClass + count: 1 + path: src/Command/Neon2Attributes/PhpClassAttributesWriter.php + + - + rawMessage: Instantiation of internal class PhpParser\Internal\TokenStream. + identifier: new.internalClass + count: 1 + path: src/Command/Neon2Attributes/PhpClassAttributesWriter.php + - rawMessage: 'Call to static method escape() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 8ccca24e3a0..c67d78bf696 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -183,21 +183,9 @@ public static function begin( })($autoloadFile); } if ($projectConfigFile === null) { - $discoverableConfigNames = [ - '.phpstan.neon', - 'phpstan.neon', - '.phpstan.neon.dist', - 'phpstan.neon.dist', - '.phpstan.dist.neon', - 'phpstan.dist.neon', - ]; - foreach ($discoverableConfigNames as $discoverableConfigName) { - $discoverableConfigFile = $currentWorkingDirectory . DIRECTORY_SEPARATOR . $discoverableConfigName; - if (is_file($discoverableConfigFile)) { - $projectConfigFile = $discoverableConfigFile; - $errorOutput->writeLineFormatted(sprintf('Note: Using configuration file %s.', $projectConfigFile)); - break; - } + $projectConfigFile = self::findDiscoverableConfigFile($currentWorkingDirectory); + if ($projectConfigFile !== null) { + $errorOutput->writeLineFormatted(sprintf('Note: Using configuration file %s.', $projectConfigFile)); } } else { $projectConfigFile = $currentWorkingDirectoryFileHelper->normalizePath($currentWorkingDirectoryFileHelper->absolutizePath($projectConfigFile)); @@ -664,4 +652,24 @@ public static function executeBootstrapFile( } } + public static function findDiscoverableConfigFile(string $currentWorkingDirectory): ?string + { + $discoverableConfigNames = [ + '.phpstan.neon', + 'phpstan.neon', + '.phpstan.neon.dist', + 'phpstan.neon.dist', + '.phpstan.dist.neon', + 'phpstan.dist.neon', + ]; + foreach ($discoverableConfigNames as $discoverableConfigName) { + $discoverableConfigFile = $currentWorkingDirectory . DIRECTORY_SEPARATOR . $discoverableConfigName; + if (is_file($discoverableConfigFile)) { + return $discoverableConfigFile; + } + } + + return null; + } + } diff --git a/src/Command/Neon2Attributes/AttributeSpec.php b/src/Command/Neon2Attributes/AttributeSpec.php new file mode 100644 index 00000000000..a4ee8de680f --- /dev/null +++ b/src/Command/Neon2Attributes/AttributeSpec.php @@ -0,0 +1,22 @@ + $arguments named arguments; the values are scalars, lists and maps of them, ClassReference and AttributeSpec for `new` + */ + public function __construct( + public string $name, + public array $arguments = [], + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/AttributesPrinter.php b/src/Command/Neon2Attributes/AttributesPrinter.php new file mode 100644 index 00000000000..1aecfc34187 --- /dev/null +++ b/src/Command/Neon2Attributes/AttributesPrinter.php @@ -0,0 +1,31 @@ +getAttribute(self::INLINE_ATTRIBUTES) !== true) { + return parent::pParam($node); + } + + $param = clone $node; + $param->attrGroups = []; + + return $this->pAttrGroups($node->attrGroups, true) . parent::pParam($param); + } + +} diff --git a/src/Command/Neon2Attributes/ClassAttributes.php b/src/Command/Neon2Attributes/ClassAttributes.php new file mode 100644 index 00000000000..0c5bb4d20e2 --- /dev/null +++ b/src/Command/Neon2Attributes/ClassAttributes.php @@ -0,0 +1,22 @@ + $reflection + * @param array $parameterAttributes by constructor parameter name + */ + public function __construct( + public ReflectionClass $reflection, + public AttributeSpec $attribute, + public array $parameterAttributes, + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/ClassReference.php b/src/Command/Neon2Attributes/ClassReference.php new file mode 100644 index 00000000000..8aa4d4c89cc --- /dev/null +++ b/src/Command/Neon2Attributes/ClassReference.php @@ -0,0 +1,15 @@ + the files not included by the project config file already + */ + public static function findMissing(string $directory, ?string $projectConfigFile): array + { + $fileHelper = new FileHelper($directory); + try { + $composer = Json::decode(FileReader::read($directory . '/composer.json'), Json::FORCE_ARRAY); + } catch (CouldNotReadFileException | JsonException) { + return []; + } + $includes = $composer['extra']['phpstan']['includes'] ?? null; + if (!is_array($includes)) { + return []; + } + + $included = []; + if ($projectConfigFile !== null) { + $loader = (new LoaderFactory($fileHelper, dirname(__DIR__, 3), $directory, null, []))->createLoader(); + try { + $loader->load($projectConfigFile, null); + } catch (InvalidStateException | FileNotFoundException) { + // reported when the container is created + return []; + } + $included = array_map(static fn (string $file): string => $fileHelper->normalizePath($file), $loader->getDependencies()); + } + + $missing = []; + foreach ($includes as $include) { + if (!is_string($include)) { + continue; + } + $file = $fileHelper->normalizePath($fileHelper->absolutizePath($include)); + if (!is_file($file) || in_array($file, $included, true)) { + continue; + } + $missing[] = $file; + } + + return $missing; + } + +} diff --git a/src/Command/Neon2Attributes/ConvertedService.php b/src/Command/Neon2Attributes/ConvertedService.php new file mode 100644 index 00000000000..71e1dddda6d --- /dev/null +++ b/src/Command/Neon2Attributes/ConvertedService.php @@ -0,0 +1,22 @@ + $attributes short names of the added attributes + */ + public function __construct( + public string $className, + public array $attributes, + public string $configFile, + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/ImportScope.php b/src/Command/Neon2Attributes/ImportScope.php new file mode 100644 index 00000000000..ae67ebbfc89 --- /dev/null +++ b/src/Command/Neon2Attributes/ImportScope.php @@ -0,0 +1,120 @@ + imported class names by lowercase alias */ + public array $uses = []; + + /** @var array lowercase first parts of names that resolve against the namespace, and of classes declared in it */ + public array $unqualifiedNames = []; + + /** + * @param list $stmts + */ + public function __construct(public string $namespace, public array $stmts) + { + foreach ($stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Use_ && $stmt->type === Node\Stmt\Use_::TYPE_NORMAL) { + foreach ($stmt->uses as $use) { + $this->uses[strtolower($use->getAlias()->toString())] = $use->name->toString(); + } + } elseif ($stmt instanceof Node\Stmt\GroupUse) { + foreach ($stmt->uses as $use) { + if ($stmt->type !== Node\Stmt\Use_::TYPE_NORMAL && $use->type !== Node\Stmt\Use_::TYPE_NORMAL) { + continue; + } + $this->uses[strtolower($use->getAlias()->toString())] = $stmt->prefix->toString() . '\\' . $use->name->toString(); + } + } elseif ($stmt instanceof Node\Stmt\ClassLike && $stmt->name !== null) { + $this->unqualifiedNames[strtolower($stmt->name->toString())] = true; + } + } + + $nodeFinder = new NodeFinder(); + $functionAndConstantNames = []; + foreach ($nodeFinder->find($stmts, static fn (Node $node): bool => $node instanceof Node\Expr\FuncCall || $node instanceof Node\Expr\ConstFetch) as $node) { + if (!$node instanceof Node\Expr\FuncCall && !$node instanceof Node\Expr\ConstFetch) { + continue; + } + $functionAndConstantNames[spl_object_id($node->name)] = true; + } + $nonUseStmts = array_filter($stmts, static fn (Node\Stmt $stmt): bool => !$stmt instanceof Node\Stmt\Use_ && !$stmt instanceof Node\Stmt\GroupUse); + foreach ($nodeFinder->findInstanceOf($nonUseStmts, Node\Name::class) as $name) { + if (!$name->isUnqualified() && !$name->isQualified()) { + continue; + } + if (array_key_exists(spl_object_id($name), $functionAndConstantNames)) { + continue; + } + + $this->unqualifiedNames[strtolower($name->getFirst())] = true; + } + } + + /** + * Inserts the use statement in alphabetical order among the existing ones. + * + * @param non-empty-string $className + */ + public function addUse(string $className, string $lowerAlias): void + { + $this->uses[$lowerAlias] = $className; + $use = new Node\Stmt\Use_([new Node\UseItem(new Node\Name($className))]); + + $position = null; + $lastClassUse = null; + $firstOtherUse = null; + $lastDeclare = null; + foreach ($this->stmts as $i => $stmt) { + if ($stmt instanceof Node\Stmt\Declare_) { + $lastDeclare = $i; + continue; + } + if (!$stmt instanceof Node\Stmt\Use_) { + continue; + } + if ($stmt->type !== Node\Stmt\Use_::TYPE_NORMAL) { + $firstOtherUse ??= $i; + continue; + } + + $lastClassUse = $i; + if ($position !== null || count($stmt->uses) !== 1 || strcasecmp($stmt->uses[0]->name->toString(), $className) <= 0) { + continue; + } + + $position = $i; + } + + if ($position === null) { + if ($lastClassUse !== null) { + $position = $lastClassUse + 1; + } elseif ($firstOtherUse !== null) { + $position = $firstOtherUse; + } else { + // the first use statement, separated from the code below it by an empty line + array_splice($this->stmts, $lastDeclare !== null ? $lastDeclare + 1 : 0, 0, [$use, new Node\Stmt\Nop()]); + return; + } + } + + array_splice($this->stmts, $position, 0, [$use]); + } + +} diff --git a/src/Command/Neon2Attributes/KeptService.php b/src/Command/Neon2Attributes/KeptService.php new file mode 100644 index 00000000000..069e844c11c --- /dev/null +++ b/src/Command/Neon2Attributes/KeptService.php @@ -0,0 +1,36 @@ + $configFiles NEON config files to migrate + * @param list $attributeServicesDirectories + * @param int $phpVersionId the lowest PHP version the attributes must work on, for #[Conditional] + * @param list $otherServiceNames names of services defined outside of $configFiles, an entry with the same name overrides them + */ + public function migrate(array $configFiles, array $attributeServicesDirectories, int $phpVersionId, array $otherServiceNames): Neon2AttributesResult + { + // reflection reports real paths, a package from a path repository is a symlink in vendor/ + $directories = array_map(static fn (string $directory): string => rtrim(strtr(realpath($directory) ?: $directory, '\\', '/'), '/'), $attributeServicesDirectories); + + /** @var array $files */ + $files = []; + /** @var list $entries */ + $entries = []; + /** @var array}> $conditionalTags by lowercase class name */ + $conditionalTags = []; + foreach ($configFiles as $file) { + try { + $code = FileReader::read($file); + $tokens = (new NeonLexer())->tokenize($code); + $root = (new NeonParser())->parse($tokens); + } catch (CouldNotReadFileException | NeonException) { + continue; + } + if (!$root instanceof NeonArrayNode) { + continue; + } + + $files[$file] = ['code' => $code, 'tokens' => $tokens, 'root' => $root]; + foreach ($root->items as $sectionItem) { + $section = $sectionItem->key !== null ? $sectionItem->key->toValue() : null; + if (!$sectionItem->value instanceof NeonArrayNode) { + continue; + } + if (in_array($section, ['rules', 'services'], true)) { + foreach ($sectionItem->value->items as $item) { + $entries[] = ['file' => $file, 'section' => $section, 'item' => $item]; + } + } elseif ($section === 'conditionalTags') { + foreach ($sectionItem->value->items as $item) { + $class = $item->key !== null ? $item->key->toValue() : null; + $tags = $item->value->toValue(); + if (!is_string($class) || !is_array($tags)) { + continue; + } + $conditionalTags[strtolower($class)] = ['file' => $file, 'item' => $item, 'tags' => $tags]; + } + } + } + } + + $definitions = []; + $classCounts = []; + foreach ($entries as $i => $entry) { + $definition = $this->createDefinition($entry['section'], $entry['item']); + $definitions[$i] = $definition; + if (is_string($definition)) { + continue; + } + $lowerClass = strtolower($definition->className); + $classCounts[$lowerClass] = ($classCounts[$lowerClass] ?? 0) + 1; + } + + $converted = []; + $kept = []; + $reportedDuplicates = []; + /** @var array> $removedItems by config file */ + $removedItems = []; + /** @var array> $classEdits by PHP file */ + $classEdits = []; + foreach ($entries as $i => $entry) { + $definition = $definitions[$i]; + $line = $entry['item']->startLine ?? 0; + if (is_string($definition)) { + $name = $this->getEntryName($entry['item']); + $kept[] = self::isOutsideClass($name, $directories) + ? new KeptService(KeptService::CATEGORY_OUTSIDE, $name, null, $entry['file'], $line) + : new KeptService(KeptService::CATEGORY_KEPT, $name, $definition, $entry['file'], $line); + continue; + } + + $lowerClass = strtolower($definition->className); + if ($classCounts[$lowerClass] > 1) { + if (!array_key_exists($lowerClass, $reportedDuplicates)) { + $reportedDuplicates[$lowerClass] = true; + $kept[] = new KeptService(KeptService::CATEGORY_DUPLICATE, $definition->className, sprintf('registered %d times', $classCounts[$lowerClass]), $entry['file'], $line, $classCounts[$lowerClass]); + } + continue; + } + + if (!class_exists($definition->className) && !interface_exists($definition->className)) { + $kept[] = new KeptService(KeptService::CATEGORY_KEPT, $definition->className, 'class not found', $entry['file'], $line); + continue; + } + + $reflection = new ReflectionClass($definition->className); + $fileName = $reflection->getFileName(); + if ($fileName === false || !self::isInDirectories(strtr($fileName, '\\', '/'), $directories)) { + $kept[] = new KeptService(KeptService::CATEGORY_OUTSIDE, $reflection->getName(), null, $entry['file'], $line); + continue; + } + if ($definition->name !== null && in_array($definition->name, $otherServiceNames, true)) { + // attributes only add services, they cannot replace one + $kept[] = new KeptService(KeptService::CATEGORY_KEPT, $reflection->getName(), sprintf('overrides service %s', $definition->name), $entry['file'], $line); + continue; + } + + $conditional = $conditionalTags[$lowerClass] ?? null; + $classAttributes = $this->createClassAttributes($entry['section'], $definition, $reflection, $conditional === null ? null : $conditional['tags'], $phpVersionId); + if (is_string($classAttributes)) { + $kept[] = new KeptService(KeptService::CATEGORY_KEPT, $reflection->getName(), $classAttributes, $entry['file'], $line); + continue; + } + + $classEdits[$fileName][] = $classAttributes; + $removedItems[$entry['file']][] = $entry['item']; + if ($conditional !== null) { + $removedItems[$conditional['file']][] = $conditional['item']; + } + + $attributes = [$classAttributes->attribute->name]; + if (count($classAttributes->parameterAttributes) > 0) { + $attributes[] = 'AutowiredParameter'; + } + $converted[] = new ConvertedService($classAttributes->reflection->getName(), $attributes, $entry['file']); + } + + $changedFiles = []; + foreach ($removedItems as $file => $items) { + $newCode = $this->removeItems($files[$file], $items); + if ($newCode === $files[$file]['code']) { + continue; + } + + $changedFiles[$file] = $newCode; + } + foreach ($classEdits as $file => $edits) { + $changedFiles[$file] = $this->attributesWriter->write($file, $edits); + } + + return new Neon2AttributesResult($changedFiles, $converted, $kept); + } + + /** + * @return ServiceDefinition|string the definition, or why it cannot be converted + */ + private function createDefinition(string $section, NeonArrayItemNode $item): ServiceDefinition|string + { + $value = $item->value->toValue(); + if ($section === 'rules') { + if (!is_string($value)) { + return 'not a class'; + } + + return new ServiceDefinition($value, null, [], null, true, [], null); + } + + $key = $item->key !== null ? $item->key->toValue() : null; + $name = is_string($key) ? $key : null; + if (is_string($value) || $value instanceof Entity) { + $value = ['class' => $value]; + } + if (!is_array($value)) { + return 'not a class'; + } + + if (array_key_exists('implement', $value)) { + return $this->createFactoryDefinition($name, $value); + } + + foreach (array_keys($value) as $serviceKey) { + if (!is_string($serviceKey) || !in_array($serviceKey, self::SERVICE_KEYS, true)) { + return sprintf('unsupported key %s', $serviceKey); + } + } + + if (array_key_exists('class', $value) && array_key_exists('type', $value)) { + return 'both class and type'; + } + + $arguments = $value['arguments'] ?? []; + // without a factory, Nette creates the type like a class + $class = $value['class'] ?? $value['type'] ?? null; + $factory = self::normalizeFactory($value['factory'] ?? $value['create'] ?? null); + if ($class === null && !self::isServiceMethodFactory($factory)) { + // `factory: ClassName` and `factory: ClassName(...)` create the class itself + $class = $factory; + $factory = null; + if (array_key_exists('arguments', $value) && $class instanceof Entity) { + return 'arguments in two places'; + } + } + if ($class instanceof Entity && is_string($class->value) && !str_starts_with($class->value, '@')) { + $arguments = $class->attributes; + $class = $class->value; + } + if ($class === null && $factory !== null) { + return 'only a factory without a class'; + } + if (!is_string($class) || str_starts_with($class, '@')) { + return 'not a class'; + } + if (!is_array($arguments)) { + return 'arguments are not a list'; + } + + // `class: Foo` with `factory: Foo` or `factory: Foo(...)` creates the class itself + $factoryClass = $factory instanceof Entity ? $factory->value : $factory; + if (is_string($factoryClass) && strcasecmp(ltrim($factoryClass, '\\'), ltrim($class, '\\')) === 0) { + if ($factory instanceof Entity) { + if (array_key_exists('arguments', $value)) { + return 'arguments in two places'; + } + $arguments = $factory->attributes; + } + $factory = null; + } + if ($factory !== null && !self::isServiceMethodFactory($factory)) { + $factoryValue = $factory instanceof Entity ? $factory->value : $factory; + if (!is_string($factoryValue)) { + return 'factory is not a method of another service'; + } + if (!str_starts_with($factoryValue, '@')) { + if (str_contains($factoryValue, '::')) { + return 'static factory method'; + } + if (is_a($factoryValue, $class, true)) { + return sprintf('service type differs from the created class %s', $factoryValue); + } + + return 'created by another class'; + } + if (str_contains($factoryValue, '%')) { + return 'factory service is chosen by a parameter'; + } + if (!str_contains($factoryValue, '::')) { + return 'factory is a reference to another service'; + } + + return 'factory is not a method of another service'; + } + if ($factory instanceof Entity) { + $factory = $factory->value; + } + + $tags = $value['tags'] ?? []; + if (!is_array($tags)) { + return 'tags with values'; + } + foreach ($tags as $tagKey => $tag) { + if (!is_int($tagKey) || !is_string($tag)) { + return 'tags with values'; + } + } + + return new ServiceDefinition($class, $name, $arguments, $factory, $value['autowired'] ?? true, array_values($tags), null); + } + + /** + * @param array $value + */ + private function createFactoryDefinition(?string $name, array $value): ServiceDefinition|string + { + foreach (array_keys($value) as $serviceKey) { + if ($serviceKey !== 'implement' && $serviceKey !== 'arguments') { + return sprintf('unsupported key %s', $serviceKey); + } + } + if ($name !== null) { + return 'a generated factory with a name'; + } + + $interface = $value['implement']; + $arguments = $value['arguments'] ?? []; + if (!is_string($interface) || !is_array($arguments)) { + return 'not a class'; + } + if (!interface_exists($interface)) { + return sprintf('interface %s not found', $interface); + } + + $reflection = new ReflectionClass($interface); + if (!$reflection->hasMethod('create')) { + return sprintf('%s has no create() method', $interface); + } + $returnType = $reflection->getMethod('create')->getReturnType(); + if (!$returnType instanceof ReflectionNamedType || $returnType->isBuiltin()) { + return sprintf('%s::create() does not return a class', $interface); + } + + return new ServiceDefinition($returnType->getName(), null, $arguments, null, true, [], $reflection->getName()); + } + + /** + * @param ReflectionClass $reflection + * @param array|null $conditionalTags + * @return ClassAttributes|string the attributes, or why the definition cannot be converted + */ + private function createClassAttributes(string $section, ServiceDefinition $definition, ReflectionClass $reflection, ?array $conditionalTags, int $phpVersionId): ClassAttributes|string + { + if (!$reflection->isInstantiable() && $definition->generatedFactory === null && $definition->factory === null) { + return 'not an instantiable class'; + } + foreach ($reflection->getAttributes() as $attribute) { + $shortName = self::getShortName($attribute->getName()); + if (str_starts_with($attribute->getName(), 'PHPStan\\DependencyInjection\\') && in_array($shortName, self::DI_ATTRIBUTES, true)) { + return sprintf('already has #[%s]', $shortName); + } + } + + $parameterAttributes = $this->createParameterAttributes($reflection, $definition->arguments); + if (is_string($parameterAttributes)) { + return $parameterAttributes; + } + + if ($definition->generatedFactory !== null) { + return new ClassAttributes($reflection, new AttributeSpec('GenerateFactory', ['interface' => new ClassReference($definition->generatedFactory)]), $parameterAttributes); + } + + if ($section === 'rules') { + if ($conditionalTags !== null) { + return 'conditionalTags of a class from the rules section'; + } + + return new ClassAttributes($reflection, new AttributeSpec('RegisteredRule', ['level' => 0]), $parameterAttributes); + } + + $interfaceTags = []; + foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + if (!$reflection->implementsInterface($interface)) { + continue; + } + $interfaceTags[] = $tag; + } + $interfaceTags = array_values(array_unique($interfaceTags)); + sort($interfaceTags); + + $knownTags = array_values(ValidateServiceTagsExtension::getInterfaceTagMapping()); + foreach ($definition->tags as $tag) { + if (!in_array($tag, $knownTags, true)) { + return sprintf('custom tag %s has no attribute equivalent', $tag); + } + } + + if ($definition->autowired === false) { + if ($definition->name === null) { + return 'autowired: false needs a service name'; + } + if (count($definition->tags) > 0 || $conditionalTags !== null) { + return 'autowired: false with tags'; + } + + $arguments = ['name' => $definition->name]; + if ($definition->factory !== null) { + $arguments['factory'] = $definition->factory; + } + + return new ClassAttributes($reflection, new AttributeSpec('NonAutowiredService', $arguments), $parameterAttributes); + } + + $arguments = []; + if ($definition->name !== null) { + $arguments['name'] = $definition->name; + } + if ($definition->factory !== null) { + $arguments['factory'] = $definition->factory; + } + $as = $this->createAs($reflection, $definition->autowired); + if (is_string($as)) { + return $as; + } + if ($as !== null) { + $arguments['as'] = $as; + } + + if ($conditionalTags !== null) { + if (count($definition->tags) > 0) { + return 'tags combined with conditionalTags'; + } + + $condition = $this->createCondition($conditionalTags, $interfaceTags); + if (is_string($condition)) { + return $condition; + } + + $inner = new AttributeSpec('AutowiredService', $arguments); + + return new ClassAttributes($reflection, new AttributeSpec('Conditional', [ + // PHP 8.0 does not support `new` in attribute arguments, AttributeServicesDiscoverer instantiates this array + 'attribute' => $phpVersionId >= 80100 ? $inner : [self::NEW_EXPRESSION_MARKER => [new ClassReference(AutowiredService::class), $inner->arguments]], + 'if' => count($condition) === 1 ? $condition[0] : $condition, + ]), $parameterAttributes); + } + + $neonTags = array_values(array_unique($definition->tags)); + sort($neonTags); + if (count($neonTags) === 0) { + if (count($interfaceTags) > 0) { + $arguments['autoTag'] = false; + } + } elseif ($neonTags !== $interfaceTags) { + $missing = array_values(array_diff($interfaceTags, $neonTags)); + if (count($missing) > 0) { + return sprintf('tags do not match the implemented extension interfaces, they would add %s', implode(', ', $missing)); + } + + return sprintf('tags do not match the implemented extension interfaces, they would not add %s', implode(', ', array_diff($neonTags, $interfaceTags))); + } + + return new ClassAttributes($reflection, new AttributeSpec('AutowiredService', $arguments), $parameterAttributes); + } + + /** + * @param ReflectionClass $reflection + * @return ClassReference|list|string|null null for the default autowiring + */ + private function createAs(ReflectionClass $reflection, mixed $autowired): ClassReference|array|string|null + { + if ($autowired === true) { + return null; + } + if ($autowired === 'self') { + return new ClassReference($reflection->getName()); + } + if (is_string($autowired)) { + return new ClassReference($autowired); + } + if (is_array($autowired) && count($autowired) > 0) { + $types = []; + foreach ($autowired as $key => $type) { + if (!is_int($key) || !is_string($type)) { + return 'unsupported autowired value'; + } + $types[] = new ClassReference($type === 'self' ? $reflection->getName() : $type); + } + + return $types; + } + + return 'unsupported autowired value'; + } + + /** + * @param ReflectionClass $reflection + * @param array $arguments + * @return array|string attributes by constructor parameter name, or why they cannot be written + */ + private function createParameterAttributes(ReflectionClass $reflection, array $arguments): array|string + { + if (count($arguments) === 0) { + return []; + } + + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return 'arguments of a class without a constructor'; + } + if (strtolower($constructor->getDeclaringClass()->getName()) !== strtolower($reflection->getName())) { + return sprintf('arguments of a constructor declared in %s', $constructor->getDeclaringClass()->getName()); + } + + $parameters = $constructor->getParameters(); + $attributes = []; + foreach ($arguments as $key => $value) { + $parameter = null; + foreach ($parameters as $position => $candidate) { + if ($key === $position || $key === $candidate->getName()) { + $parameter = $candidate; + break; + } + } + if ($parameter === null) { + return sprintf('argument %s is not a constructor parameter', is_int($key) ? '#' . ($key + 1) : '$' . $key); + } + + $parameterName = $parameter->getName(); + if (is_array($value)) { + return sprintf('argument $%s is an inline array', $parameterName); + } + if ($value instanceof Entity) { + return sprintf('argument $%s is an expression', $parameterName); + } + if (!is_string($value)) { + return sprintf('argument $%s is not a string', $parameterName); + } + if (str_starts_with($value, '@') && preg_match('#^@[\w.\\\\]+$#D', $value) !== 1) { + return sprintf('argument $%s is not a service reference', $parameterName); + } + $existingAttributes = $parameter->getAttributes(AutowiredParameter::class); + if (count($existingAttributes) > 0) { + if (($existingAttributes[0]->newInstance()->ref ?? '%' . $parameterName . '%') !== $value) { + return sprintf('argument $%s already has a different #[AutowiredParameter]', $parameterName); + } + continue; + } + + $attributes[$parameterName] = new AttributeSpec( + 'AutowiredParameter', + $value === '%' . $parameterName . '%' ? [] : ['ref' => $value], + ); + } + + return $attributes; + } + + /** + * @param array $conditionalTags + * @param list $interfaceTags + * @return non-empty-list|string the conditions that all must be met, or why they cannot be converted + */ + private function createCondition(array $conditionalTags, array $interfaceTags): string|array + { + $conditions = []; + foreach ($conditionalTags as $condition) { + $converted = self::convertCondition($condition); + if ($converted === null) { + return 'conditionalTags with a condition that is not a parameter'; + } + $conditions[] = $converted; + } + $first = $conditions[0]; + foreach ($conditions as $condition) { + if ($condition !== $first) { + return 'conditionalTags with different conditions'; + } + } + + $tags = array_keys($conditionalTags); + sort($tags); + if ($tags !== $interfaceTags) { + return 'conditionalTags do not match the implemented extension interfaces'; + } + + return is_string($first) ? [$first] : $first; + } + + /** + * @return string|non-empty-list|null + */ + private static function convertCondition(mixed $condition): string|array|null + { + if (is_array($condition)) { + $list = []; + foreach ($condition as $key => $item) { + $converted = is_int($key) ? self::convertCondition($item) : null; + if (!is_string($converted)) { + return null; + } + $list[] = $converted; + } + + return count($list) > 0 ? $list : null; + } + if ($condition instanceof Entity) { + if ($condition->value !== 'not' || count($condition->attributes) !== 1) { + return null; + } + $negated = self::convertCondition($condition->attributes[0] ?? null); + if (!is_string($negated)) { + return null; + } + + return sprintf('not(%s)', $negated); + } + if (is_string($condition) && str_contains($condition, '%')) { + return $condition; + } + + return null; + } + + /** + * @param array{code: string, tokens: NeonTokenStream, root: NeonArrayNode} $file + * @param list $items + */ + private function removeItems(array $file, array $items): string + { + $root = $file['root']; + $newRoot = clone $root; + $newRoot->items = []; + foreach ($root->items as $sectionItem) { + if (!$sectionItem->value instanceof NeonArrayNode) { + $newRoot->items[] = $sectionItem; + continue; + } + + $remaining = []; + foreach ($sectionItem->value->items as $item) { + if (in_array($item, $items, true)) { + continue; + } + $remaining[] = $item; + } + if (count($remaining) === count($sectionItem->value->items)) { + $newRoot->items[] = $sectionItem; + continue; + } + if (count($remaining) === 0) { + continue; + } + + $newValue = clone $sectionItem->value; + $newValue->items = $remaining; + $newSectionItem = clone $sectionItem; + $newSectionItem->value = $newValue; + $newRoot->items[] = $newSectionItem; + } + + $code = $file['code']; + if (count($newRoot->items) === 0) { + return ''; + } + $printed = $this->neonPrinter->printFormatPreserving($newRoot, $root, $file['tokens']); + // nette/neon removes \r, it is put back when every line break had it + $codeWithoutCr = str_replace("\r", '', $code); + if ($codeWithoutCr !== $code && str_replace("\n", "\r\n", $codeWithoutCr) === $code) { + $printed = str_replace("\n", "\r\n", $printed); + } + + return $printed; + } + + private function getEntryName(NeonArrayItemNode $item): string + { + $key = $item->key !== null ? $item->key->toValue() : null; + if (is_string($key)) { + return $key; + } + + $value = $item->value->toValue(); + if (is_string($value)) { + return $value; + } + $class = is_array($value) ? $value['class'] ?? $value['type'] ?? null : null; + if (is_string($class)) { + return $class; + } + if ($class instanceof Entity && is_string($class->value)) { + return $class->value; + } + $factory = is_array($value) ? $value['factory'] ?? null : null; + if ($factory instanceof Entity && is_string($factory->value)) { + $factory = $factory->value . '()'; + } + if (is_string($factory)) { + return sprintf('factory %s', $factory); + } + + $printed = $item->value->toString(); + + return str_contains($printed, "\n") ? 'unnamed service' : $printed; + } + + /** + * Turns `[@service, method]` into `@service::method`. + */ + private static function normalizeFactory(mixed $factory): mixed + { + if (is_array($factory) && array_keys($factory) === [0, 1] && is_string($factory[0]) && is_string($factory[1]) && str_starts_with($factory[0], '@')) { + return $factory[0] . '::' . $factory[1]; + } + + return $factory; + } + + private static function isServiceMethodFactory(mixed $factory): bool + { + if ($factory instanceof Entity && $factory->attributes === []) { + $factory = $factory->value; + } + + return is_string($factory) && preg_match('#^@[\w\\\\.]+::\w+$#D', $factory) === 1; + } + + /** + * @param list $directories + */ + private static function isOutsideClass(string $name, array $directories): bool + { + if (!class_exists($name) && !interface_exists($name)) { + return false; + } + $fileName = (new ReflectionClass($name))->getFileName(); + + return $fileName === false || !self::isInDirectories(strtr($fileName, '\\', '/'), $directories); + } + + /** + * @param list $directories + */ + private static function isInDirectories(string $file, array $directories): bool + { + $file = strtr(realpath($file) ?: $file, '\\', '/'); + foreach ($directories as $directory) { + if (str_starts_with($file, $directory . '/')) { + return true; + } + } + + return false; + } + + private static function getShortName(string $className): string + { + $position = strrpos($className, '\\'); + + return $position === false ? $className : substr($className, $position + 1); + } + +} diff --git a/src/Command/Neon2Attributes/Neon2AttributesResult.php b/src/Command/Neon2Attributes/Neon2AttributesResult.php new file mode 100644 index 00000000000..4f383af04c9 --- /dev/null +++ b/src/Command/Neon2Attributes/Neon2AttributesResult.php @@ -0,0 +1,45 @@ + $changedFiles new contents by path + * @param list $convertedServices + * @param list $keptServices + */ + public function __construct( + private array $changedFiles, + private array $convertedServices, + private array $keptServices, + ) + { + } + + /** + * @return array + */ + public function getChangedFiles(): array + { + return $this->changedFiles; + } + + /** + * @return list + */ + public function getConvertedServices(): array + { + return $this->convertedServices; + } + + /** + * @return list + */ + public function getKeptServices(): array + { + return $this->keptServices; + } + +} diff --git a/src/Command/Neon2Attributes/PhpClassAttributesWriter.php b/src/Command/Neon2Attributes/PhpClassAttributesWriter.php new file mode 100644 index 00000000000..235ed7d55e5 --- /dev/null +++ b/src/Command/Neon2Attributes/PhpClassAttributesWriter.php @@ -0,0 +1,221 @@ + $edits classes declared in the file + * @return string the new contents of the file + */ + public function write(string $file, array $edits): string + { + $code = FileReader::read($file); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $oldStmts = $parser->parse($code); + if ($oldStmts === null) { + throw new ShouldNotHappenException(); + } + $tokens = $parser->getTokens(); + + $indentDetector = new PhpPrinterIndentationDetectorVisitor(new TokenStream($tokens, PhpPrinter::TAB_WIDTH)); + /** @var list $newStmts */ + $newStmts = (new NodeTraverser($indentDetector, new CloningVisitor()))->traverse($oldStmts); + $indent = str_contains($indentDetector->indentCharacter, "\t") + ? "\t" + : str_repeat($indentDetector->indentCharacter, $indentDetector->indentSize); + + foreach ($edits as $edit) { + $this->addAttributes($file, $newStmts, $edit); + } + + return (new AttributesPrinter(['indent' => $indent]))->printFormatPreserving($newStmts, $oldStmts, $tokens); + } + + /** + * @param list $stmts + */ + private function addAttributes(string $file, array &$stmts, ClassAttributes $edit): void + { + $className = $edit->reflection->getName(); + $namespace = $edit->reflection->getNamespaceName(); + $shortName = $edit->reflection->getShortName(); + + if ($namespace === '') { + $this->addAttributesInScope($stmts, '', $shortName, $edit); + return; + } + + foreach ($stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\Namespace_ || $stmt->name === null || strcasecmp($stmt->name->toString(), $namespace) !== 0) { + continue; + } + if ($this->addAttributesInScope($stmt->stmts, $namespace, $shortName, $edit)) { + return; + } + } + + throw new ShouldNotHappenException(sprintf('Class %s not found in %s.', $className, $file)); + } + + /** + * @param list $stmts statements of a namespace, or of a file without one + */ + private function addAttributesInScope(array &$stmts, string $namespace, string $shortName, ClassAttributes $edit): bool + { + $class = null; + foreach ($stmts as $stmt) { + // an interface carries the attribute of a service created by a factory + if (!($stmt instanceof Node\Stmt\Class_ || $stmt instanceof Node\Stmt\Interface_) || $stmt->name === null || strcasecmp($stmt->name->toString(), $shortName) !== 0) { + continue; + } + $class = $stmt; + break; + } + if ($class === null) { + return false; + } + + $scope = new ImportScope($namespace, $stmts); + $class->attrGroups[] = new Node\AttributeGroup([$this->createAttribute($scope, $edit->attribute)]); + + $constructor = $class->getMethod('__construct'); + $isOneLine = $constructor !== null + && count($constructor->params) > 0 + && $constructor->name->getStartLine() === $constructor->params[count($constructor->params) - 1]->getEndLine(); + foreach ($edit->parameterAttributes as $parameterName => $attribute) { + if ($constructor === null) { + throw new ShouldNotHappenException(); + } + foreach ($constructor->params as $param) { + if (!$param->var instanceof Node\Expr\Variable || $param->var->name !== $parameterName) { + continue; + } + + $param->attrGroups[] = new Node\AttributeGroup([$this->createAttribute($scope, $attribute)]); + $param->setAttribute(AttributesPrinter::INLINE_ATTRIBUTES, $isOneLine); + } + } + + $stmts = $scope->stmts; + + return true; + } + + private function createAttribute(ImportScope $scope, AttributeSpec $attribute): Node\Attribute + { + return new Node\Attribute($this->createName($scope, self::DI_NAMESPACE . $attribute->name), $this->createArgs($scope, $attribute)); + } + + /** + * @return list + */ + private function createArgs(ImportScope $scope, AttributeSpec $attribute): array + { + $args = []; + foreach ($attribute->arguments as $name => $value) { + $args[] = new Node\Arg($this->createExpr($scope, $value), name: new Node\Identifier($name)); + } + + return $args; + } + + private function createExpr(ImportScope $scope, mixed $value): Node\Expr + { + if ($value instanceof AttributeSpec) { + return new Node\Expr\New_($this->createName($scope, self::DI_NAMESPACE . $value->name), $this->createArgs($scope, $value)); + } + if ($value instanceof ClassReference) { + return new Node\Expr\ClassConstFetch($this->createName($scope, $value->className), 'class'); + } + if (is_string($value)) { + return new Node\Scalar\String_($value); + } + if (is_int($value)) { + return new Node\Scalar\Int_($value); + } + if (is_bool($value)) { + return new Node\Expr\ConstFetch(new Node\Name($value ? 'true' : 'false')); + } + if (is_array($value)) { + $items = []; + $isList = array_is_list($value); + foreach ($value as $key => $item) { + $items[] = new Node\ArrayItem( + $this->createExpr($scope, $item), + $isList ? null : $this->createExpr($scope, $key), + ); + } + + return new Node\Expr\Array_($items, ['kind' => Node\Expr\Array_::KIND_SHORT]); + } + + throw new ShouldNotHappenException(); + } + + /** + * Uses the short name and adds a use statement unless the short name already means another class in the file. + */ + private function createName(ImportScope $scope, string $className): Node\Name + { + $className = ltrim($className, '\\'); + $position = strrpos($className, '\\'); + $shortName = $position === false ? $className : substr($className, $position + 1); + if ($className === '' || $shortName === '') { + throw new ShouldNotHappenException(sprintf('Invalid class name %s.', $className)); + } + $lowerShortName = strtolower($shortName); + + if (array_key_exists($lowerShortName, $scope->uses)) { + return strcasecmp($scope->uses[$lowerShortName], $className) === 0 + ? new Node\Name($shortName) + : new Node\Name\FullyQualified($className); + } + + $localName = $scope->namespace === '' ? $shortName : $scope->namespace . '\\' . $shortName; + if (strcasecmp($localName, $className) === 0) { + return new Node\Name($shortName); + } + if (array_key_exists($lowerShortName, $scope->unqualifiedNames)) { + return new Node\Name\FullyQualified($className); + } + + $scope->addUse($className, $lowerShortName); + + return new Node\Name($shortName); + } + +} diff --git a/src/Command/Neon2Attributes/ServiceDefinition.php b/src/Command/Neon2Attributes/ServiceDefinition.php new file mode 100644 index 00000000000..a0791b66e30 --- /dev/null +++ b/src/Command/Neon2Attributes/ServiceDefinition.php @@ -0,0 +1,28 @@ + $arguments + * @param list $tags + * @param string|null $generatedFactory interface of a generated factory, the class is the result type of its create() method + */ + public function __construct( + public string $className, + public ?string $name, + public array $arguments, + public ?string $factory, + public mixed $autowired, + public array $tags, + public ?string $generatedFactory, + ) + { + } + +} diff --git a/src/Command/Neon2AttributesCommand.php b/src/Command/Neon2AttributesCommand.php new file mode 100644 index 00000000000..0a6481af65b --- /dev/null +++ b/src/Command/Neon2AttributesCommand.php @@ -0,0 +1,359 @@ +setName(self::NAME) + ->setDescription('Replaces services in the project\'s NEON config files with attributes on the service classes.') + ->setDefinition([ + new InputOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'Path to project configuration file'), + new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), + new InputOption('debug', mode: InputOption::VALUE_NONE, description: 'Show debug information - do not catch internal errors'), + new InputOption('memory-limit', mode: InputOption::VALUE_REQUIRED, description: 'Memory limit for the migration'), + new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes (pcov and uopz are still turned off)'), + ]); + } + + #[Override] + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if (!(bool) $input->getOption('debug')) { + return; + } + + $application = $this->getApplication(); + if ($application === null) { + throw new ShouldNotHappenException(); + } + $application->setCatchExceptions(false); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $autoloadFile = $input->getOption('autoload-file'); + $configuration = $input->getOption('configuration'); + $memoryLimit = $input->getOption('memory-limit'); + $debugEnabled = (bool) $input->getOption('debug'); + $allowXdebug = $input->getOption('xdebug'); + + if ( + (!is_string($autoloadFile) && $autoloadFile !== null) + || (!is_string($configuration) && $configuration !== null) + || (!is_string($memoryLimit) && $memoryLimit !== null) + || (!is_bool($allowXdebug)) + ) { + throw new ShouldNotHappenException(); + } + + if (PHP_VERSION_ID < 80000) { + $output->writeln(sprintf('The migration reads and writes PHP attributes, run it on PHP 8.0 or later. You are running PHP %s.', PHP_VERSION)); + return 1; + } + + // an extension's own config files are included by phpstan/extension-installer in the projects + // that install it, not by its phpstan.neon, they are migrated too + $currentWorkingDirectory = getcwd(); + if ($currentWorkingDirectory === false) { + throw new ShouldNotHappenException(); + } + $fileHelper = new FileHelper($currentWorkingDirectory); + $projectConfigFile = $configuration !== null + ? $fileHelper->normalizePath($fileHelper->absolutizePath($configuration)) + : CommandHelper::findDiscoverableConfigFile($currentWorkingDirectory); + $composerIncludes = ComposerConfigIncludes::findMissing($currentWorkingDirectory, $projectConfigFile); + $generatedConfigFile = null; + if (count($composerIncludes) > 0) { + $generatedConfigFile = sprintf('%s/phpstan-neon2attributes-%s.neon', sys_get_temp_dir(), uniqid()); + FileWriter::write($generatedConfigFile, Neon::encode([ + 'includes' => $projectConfigFile !== null ? [$projectConfigFile, ...$composerIncludes] : $composerIncludes, + ], true)); + $output->writeln(sprintf( + 'Note: Using %sextra.phpstan.includes from composer.json: %s.', + $projectConfigFile !== null ? sprintf('configuration file %s and ', $projectConfigFile) : '', + implode(', ', $composerIncludes), + )); + $configuration = $generatedConfigFile; + } + + try { + return $this->migrate($input, $output, $memoryLimit, $autoloadFile, $configuration, $allowXdebug, $debugEnabled, $generatedConfigFile); + } finally { + if ($generatedConfigFile !== null) { + @unlink($generatedConfigFile); + } + } + } + + private function migrate( + InputInterface $input, + OutputInterface $output, + ?string $memoryLimit, + ?string $autoloadFile, + ?string $configuration, + bool $allowXdebug, + bool $debugEnabled, + ?string $generatedConfigFile, + ): int + { + try { + $inceptionResult = CommandHelper::begin( + $input, + $output, + [], + $memoryLimit, + $autoloadFile, + $this->composerAutoloaderProjectPaths, + $configuration, + null, + '0', + $allowXdebug, + $debugEnabled, + null, + null, + false, + ); + } catch (InceptionNotSuccessfulException) { + return 1; + } + + $container = $inceptionResult->getContainer(); + $style = $inceptionResult->getStdOutput()->getStyle(); + $errorStyle = $inceptionResult->getErrorOutput()->getStyle(); + + $phpVersion = $container->getByType(ComposerPhpVersionFactory::class)->getMinVersion() ?? $container->getByType(PhpVersion::class); + if ($phpVersion->getVersionId() < 80000) { + $errorStyle->error(sprintf( + "The project supports PHP %s. PHPStan reads the attributes with native reflection, which needs PHP 8.0.\nRaise the PHP requirement in composer.json to 8.0 or later first.", + $phpVersion->getVersionString(), + )); + return 1; + } + + $directories = $container->getParameter('attributeServicesDirectories'); + if (!is_array($directories) || count($directories) === 0) { + $errorStyle->error(implode("\n", [ + 'The attributeServicesDirectories section is missing in the configuration.', + 'List the directories with the service classes in it first, for example:', + '', + 'attributeServicesDirectories:', + ' - src', + ])); + return 1; + } + + $packageDependencyResolver = $container->getByType(PackageDependencyResolver::class); + /** @var list $allConfigFiles */ + $allConfigFiles = $container->getParameter('allConfigFiles'); + $configFiles = array_values(array_filter( + $allConfigFiles, + static fn (string $file): bool => str_ends_with($file, '.neon') + && $file !== $generatedConfigFile + && !str_starts_with($file, 'phar://') + && !$packageDependencyResolver->isFileOfInstalledPackage($file), + )); + + /** @var list $directories */ + $result = $container->getByType(Neon2AttributesMigrator::class)->migrate( + $configFiles, + $directories, + $phpVersion->getVersionId(), + self::findOtherServiceNames(array_values(array_diff($allConfigFiles, $configFiles))), + ); + + /** @var RelativePathHelper $relativePathHelper */ + $relativePathHelper = $container->getService('simpleRelativePathHelper'); + + foreach ($result->getChangedFiles() as $file => $contents) { + FileWriter::write($file, $contents); + } + + $converted = $result->getConvertedServices(); + $kept = $result->getKeptServices(); + $keptByCategory = [ + KeptService::CATEGORY_OUTSIDE => [], + KeptService::CATEGORY_DUPLICATE => [], + KeptService::CATEGORY_KEPT => [], + ]; + foreach ($kept as $service) { + $keptByCategory[$service->category][] = $service; + } + + $convertedByFile = []; + foreach ($converted as $service) { + $convertedByFile[$service->configFile] = ($convertedByFile[$service->configFile] ?? 0) + 1; + } + $style->section('Config files'); + $style->listing(array_map(static function (string $file) use ($relativePathHelper, $convertedByFile): string { + $count = $convertedByFile[$file] ?? 0; + + return sprintf('%s: %s', $relativePathHelper->getRelativePath($file), $count === 0 ? 'no change' : sprintf('%d %s converted', $count, $count === 1 ? 'service' : 'services')); + }, $configFiles)); + + if (count($converted) > 0 && $inceptionResult->getStdOutput()->isVerbose()) { + $style->section(sprintf('Converted services (%d)', count($converted))); + $style->listing(array_map( + static fn (ConvertedService $service): string => sprintf('%s: %s', $service->className, implode(', ', array_map(static fn (string $attribute): string => '#[' . $attribute . ']', $service->attributes))), + $converted, + )); + } + + $sections = [ + KeptService::CATEGORY_OUTSIDE => 'Services outside of attributeServicesDirectories (%d)', + KeptService::CATEGORY_DUPLICATE => 'Classes registered as multiple services (%d)', + KeptService::CATEGORY_KEPT => 'Services the attributes cannot express (%d)', + ]; + foreach ($sections as $category => $title) { + $services = $keptByCategory[$category]; + if (count($services) === 0) { + continue; + } + + $style->section(sprintf($title, count($services))); + $style->listing(array_map(static function (KeptService $service) use ($relativePathHelper): string { + $location = sprintf('%s:%d', $relativePathHelper->getRelativePath($service->configFile), $service->line); + if ($service->reason === null) { + return sprintf('%s %s', $service->name, $location); + } + + return sprintf('%s: %s %s', $service->name, $service->reason, $location); + }, $services)); + } + + $usedAttributes = []; + foreach ($converted as $service) { + foreach ($service->attributes as $attribute) { + $usedAttributes[$attribute] = true; + } + } + if (count($converted) === 0) { + $style->warning('No services were converted.'); + return 0; + } + + $keptEntries = 0; + foreach ($kept as $service) { + $keptEntries += $service->entries; + } + $style->success(sprintf( + "Converted %d %s, %d %s in the config files.%s\nAttributes used: %s%s", + count($converted), + count($converted) === 1 ? 'service' : 'services', + $keptEntries, + $keptEntries === 1 ? 'entry stays' : 'entries stay', + $inceptionResult->getStdOutput()->isVerbose() ? '' : ' Run with -v to list the converted services.', + implode(', ', array_keys($usedAttributes)), + $phpVersion->getVersionId() < 80100 && isset($usedAttributes['Conditional']) + ? sprintf("\n#[Conditional] is written in the PHP 8.0 compatible form because the project supports PHP %s.", $phpVersion->getVersionString()) + : '', + )); + + return 0; + } + + /** + * Services defined by PHPStan's own attributes and by the config files that are not migrated. + * + * @param list $configFiles + * @return list + */ + private static function findOtherServiceNames(array $configFiles): array + { + $names = []; + $targets = AttributeTargetsProvider::create(); + foreach ($targets->findTargetClasses(AutowiredService::class) as $target) { + $names[] = $target->attribute->name; + } + foreach ($targets->findTargetClasses(NonAutowiredService::class) as $target) { + $names[] = $target->attribute->name; + } + foreach ($targets->findTargetClasses(Conditional::class) as $target) { + $attribute = $target->attribute->attribute; + if (!$attribute instanceof AutowiredService) { + continue; + } + $names[] = $attribute->name; + } + + foreach ($configFiles as $file) { + if (!str_ends_with($file, '.neon')) { + continue; + } + try { + $config = Neon::decode(FileReader::read($file)); + } catch (CouldNotReadFileException | NeonException) { + continue; + } + if (!is_array($config) || !is_array($config['services'] ?? null)) { + continue; + } + foreach (array_keys($config['services']) as $name) { + $names[] = $name; + } + } + + return array_values(array_filter($names, static fn ($name): bool => is_string($name))); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/ComposerConfigIncludesTest.php b/tests/PHPStan/Command/Neon2Attributes/ComposerConfigIncludesTest.php new file mode 100644 index 00000000000..e4cc0d50b57 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/ComposerConfigIncludesTest.php @@ -0,0 +1,41 @@ +}> + */ + public static function dataFindMissing(): iterable + { + yield 'one already included by the project config' => ['included', 'phpstan.neon', ['rules.neon']]; + yield 'none included by the project config' => ['not-included', 'phpstan.neon', ['extension.neon', 'rules.neon']]; + yield 'without a project config' => ['included', null, ['extension.neon', 'rules.neon']]; + yield 'without extra.phpstan.includes' => ['no-extra', null, []]; + yield 'without composer.json' => ['no-composer', null, []]; + } + + /** + * @param list $expected + */ + #[DataProvider('dataFindMissing')] + public function testFindMissing(string $case, ?string $projectConfigFile, array $expected): void + { + $directory = strtr(__DIR__, '\\', '/') . '/composer-includes/' . $case; + $this->assertSame( + array_map(static fn (string $file): string => $directory . '/' . $file, $expected), + array_map(static fn (string $file): string => strtr($file, '\\', '/'), ComposerConfigIncludes::findMissing( + $directory, + $projectConfigFile === null ? null : $directory . '/' . $projectConfigFile, + )), + ); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php new file mode 100644 index 00000000000..add131a77f7 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php @@ -0,0 +1,239 @@ += 8.0.0')] +class Neon2AttributesMigratorTest extends PHPStanTestCase +{ + + /** + * @return iterable, 3?: list}> + */ + public static function dataMigrate(): iterable + { + yield 'rules section' => ['rules-section', 80100, [ + 'MyRule: RegisteredRule', + ]]; + + yield 'plain service' => ['plain-service', 80100, [ + 'StringEntry: AutowiredService', + 'ClassEntry: AutowiredService', + ]]; + + yield 'tags' => ['tags', 80100, [ + 'TaggedExtension: AutowiredService', + 'UntaggedExtension: AutowiredService', + 'TaggedRule: AutowiredService', + 'kept PartiallyTaggedExtension: tags do not match the implemented extension interfaces, they would add phpstan.constants.alwaysUsedClassConstantsExtension', + 'kept UnknownTag: custom tag my.own.tag has no attribute equivalent', + ]]; + + yield 'named service' => ['named-service', 80100, [ + 'Named: AutowiredService', + 'NamedString: AutowiredService', + ]]; + + yield 'factory' => ['factory', 80100, [ + 'Created: AutowiredService', + 'CreatedFactory: AutowiredService', + 'ArrayCreated: AutowiredService', + 'TypedInterface: AutowiredService', + 'SameClass: AutowiredService', + 'SameClassWithArguments: AutowiredService, AutowiredParameter', + 'kept StaticallyCreated: static factory method', + 'kept ParameterCreated: factory service is chosen by a parameter', + 'kept Aliased: factory is a reference to another service', + 'kept CreatedInterface: service type differs from the created class Neon2AttributesFixtures\\Factory\\Implementation', + ]]; + + yield 'autowired' => ['autowired', 80100, [ + 'AsOneType: AutowiredService', + 'AsTypes: AutowiredService', + 'NotAutowired: NonAutowiredService', + 'kept NotAutowiredWithoutName: autowired: false needs a service name', + 'kept NotAutowiredTagged: autowired: false with tags', + ]]; + + yield 'arguments' => ['arguments', 80100, [ + 'Named: AutowiredService, AutowiredParameter', + 'Positional: AutowiredService, AutowiredParameter', + 'Entity: AutowiredService, AutowiredParameter', + 'OnlyNamed: AutowiredService', + 'DottedReference: AutowiredService, AutowiredParameter', + 'AlreadyParameter: AutowiredService, AutowiredParameter', + 'kept NonString: argument $flag is not a string', + 'kept Inherited: arguments of a constructor declared in Neon2AttributesFixtures\Arguments\ParentWithConstructor', + 'kept Call: argument $value is an expression', + 'kept InlineArray: argument $list is an inline array', + 'kept DifferentParameter: argument $checkUninitializedProperties already has a different #[AutowiredParameter]', + ]]; + + yield 'generate factory' => ['generate-factory', 80100, [ + 'Value: GenerateFactory', + 'ValueWithArguments: GenerateFactory, AutowiredParameter', + ]]; + + $conditional = [ + 'ConditionalRule: Conditional', + 'NegatedRule: Conditional', + 'ListRule: Conditional', + 'ConditionalExtension: Conditional, AutowiredParameter', + 'kept TaggedAndConditional: tags combined with conditionalTags', + 'kept DifferentConditions: conditionalTags with different conditions', + 'kept RuleSectionConditional: conditionalTags of a class from the rules section', + ]; + yield 'conditional PHP 8.1' => ['conditional-php81', 80100, $conditional]; + yield 'conditional PHP 8.0' => ['conditional-php80', 80000, $conditional]; + + yield 'duplicates' => ['duplicates', 80100, [ + 'duplicate TwiceInServices: registered 2 times', + 'duplicate InServicesAndRules: registered 2 times', + 'duplicate InTwoFiles: registered 2 times', + ]]; + + yield 'outside directories' => ['outside-directories', 80100, [ + 'Inside\InsideService: AutowiredService', + 'outside OutsideService', + 'outside PHPStan\Rules\Properties\PropertyDescriptor', + 'outside OutsideStatic', + 'outside PHPStan\Command\ErrorFormatter\GithubErrorFormatter', + ], ['errorFormatter.github']]; + + yield 'unsupported keys' => ['unsupported-keys', 80100, [ + 'kept WithSetup: unsupported key setup', + 'kept TagsWithValues: tags with values', + 'kept AbstractService: not an instantiable class', + 'kept AlreadyAttributed: already has #[AutowiredService]', + 'kept alias: not a class', + ]]; + + yield 'formatting' => ['formatting', 80100, [ + 'Converted: AutowiredService', + 'kept Kept: unsupported key setup', + 'kept AlsoKept: unsupported key setup', + ]]; + + yield 'misc' => ['misc', 80100, [ + 'FactoryKey: AutowiredService', + 'FactoryEntity: AutowiredService, AutowiredParameter', + 'AutowiredSelf: AutowiredService', + 'TypeEntry: AutowiredService', + 'kept Missing: class not found', + 'kept CreatedInterface: created by another class', + 'kept factory @FactoryEntity::create(): only a factory without a class', + ]]; + + yield 'overrides' => ['overrides', 80100, [ + 'Own: AutowiredService', + 'kept JunitFormatter: overrides service errorFormatter.junit', + ], ['errorFormatter.junit']]; + + yield 'imports' => ['imports', 80100, [ + 'SortedImports: AutowiredService', + 'ExistingImport: AutowiredService', + 'ConflictingImport: AutowiredService', + 'WithDocComment: AutowiredService', + ]]; + } + + /** + * @param list $expectedReport + * @param list $otherServiceNames + */ + #[DataProvider('dataMigrate')] + public function testMigrate(string $case, int $phpVersionId, array $expectedReport, array $otherServiceNames = []): void + { + $directory = __DIR__ . '/data/' . $case; + $configFiles = glob($directory . '/*.neon'); + $this->assertNotFalse($configFiles); + sort($configFiles); + + $attributeServicesDirectory = $case === 'outside-directories' ? $directory . '/Inside' : $directory; + $migrator = self::getContainer()->getByType(Neon2AttributesMigrator::class); + $result = $migrator->migrate($configFiles, [$attributeServicesDirectory], $phpVersionId, $otherServiceNames); + + $namespace = 'Neon2AttributesFixtures\\' . str_replace('-', '', ucwords($case, '-')) . '\\'; + $report = []; + foreach ($result->getConvertedServices() as $converted) { + $report[] = sprintf('%s: %s', str_replace($namespace, '', $converted->className), implode(', ', $converted->attributes)); + } + foreach ($result->getKeptServices() as $kept) { + $report[] = $kept->reason === null + ? sprintf('%s %s', $kept->category, str_replace($namespace, '', $kept->name)) + : sprintf('%s %s: %s', $kept->category, str_replace($namespace, '', $kept->name), $kept->reason); + } + $this->assertSame($expectedReport, $report); + + $changedFiles = $result->getChangedFiles(); + $files = [...$configFiles, ...self::findPhpFiles($directory)]; + foreach ($files as $file) { + $expectedFile = $file . '.expected'; + $expected = is_file($expectedFile) ? FileReader::read($expectedFile) : FileReader::read($file); + $this->assertSame($expected, $changedFiles[$file] ?? FileReader::read($file), sprintf('File %s', $file)); + } + $this->assertSame([], array_values(array_diff(array_keys($changedFiles), $files))); + } + + /** + * A package installed from a path repository is a symlink in vendor/, while reflection reports the real path. + */ + public function testSymlinkedDirectory(): void + { + $directory = __DIR__ . '/data/plain-service'; + $link = sys_get_temp_dir() . '/neon2attributes-symlink-' . uniqid(); + if (!@symlink($directory, $link)) { + $this->markTestSkipped('Cannot create a symlink.'); + } + + try { + $configFiles = glob($link . '/*.neon'); + $this->assertNotFalse($configFiles); + $result = self::getContainer()->getByType(Neon2AttributesMigrator::class)->migrate($configFiles, [$link], 80100, []); + } finally { + unlink($link); + } + + $this->assertSame([], $result->getKeptServices()); + $this->assertCount(2, $result->getConvertedServices()); + } + + /** + * @return list + */ + private static function findPhpFiles(string $directory): array + { + $files = []; + foreach ([$directory . '/*.php', $directory . '/*/*.php'] as $pattern) { + $found = glob($pattern); + if ($found === false) { + continue; + } + foreach ($found as $file) { + $files[] = $file; + } + } + sort($files); + + return $files; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/composer.json b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/composer.json new file mode 100644 index 00000000000..0e4ee732d3e --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/composer.json @@ -0,0 +1,11 @@ +{ + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon", + "missing.neon" + ] + } + } +} diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/extension.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/extension.neon new file mode 100644 index 00000000000..51ee79b4e4d --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/extension.neon @@ -0,0 +1 @@ +services: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/phpstan.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/phpstan.neon new file mode 100644 index 00000000000..401ec893f70 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/phpstan.neon @@ -0,0 +1,2 @@ +includes: + - extension.neon diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/rules.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/rules.neon new file mode 100644 index 00000000000..14ba58ba315 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/included/rules.neon @@ -0,0 +1 @@ +rules: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/extension.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/extension.neon new file mode 100644 index 00000000000..51ee79b4e4d --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/extension.neon @@ -0,0 +1 @@ +services: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/rules.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/rules.neon new file mode 100644 index 00000000000..14ba58ba315 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-composer/rules.neon @@ -0,0 +1 @@ +rules: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/composer.json b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/composer.json new file mode 100644 index 00000000000..423eb604c93 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/composer.json @@ -0,0 +1,3 @@ +{ + "name": "foo/bar" +} diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/extension.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/extension.neon new file mode 100644 index 00000000000..51ee79b4e4d --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/extension.neon @@ -0,0 +1 @@ +services: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/rules.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/rules.neon new file mode 100644 index 00000000000..14ba58ba315 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/no-extra/rules.neon @@ -0,0 +1 @@ +rules: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/composer.json b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/composer.json new file mode 100644 index 00000000000..0e4ee732d3e --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/composer.json @@ -0,0 +1,11 @@ +{ + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon", + "missing.neon" + ] + } + } +} diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/extension.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/extension.neon new file mode 100644 index 00000000000..51ee79b4e4d --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/extension.neon @@ -0,0 +1 @@ +services: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/phpstan.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/phpstan.neon new file mode 100644 index 00000000000..edeb6551955 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/phpstan.neon @@ -0,0 +1,2 @@ +parameters: + level: 8 diff --git a/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/rules.neon b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/rules.neon new file mode 100644 index 00000000000..14ba58ba315 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/composer-includes/not-included/rules.neon @@ -0,0 +1 @@ +rules: [] diff --git a/tests/PHPStan/Command/Neon2Attributes/data/arguments/AlreadyParameter.php b/tests/PHPStan/Command/Neon2Attributes/data/arguments/AlreadyParameter.php new file mode 100644 index 00000000000..97f02fa27aa --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/arguments/AlreadyParameter.php @@ -0,0 +1,18 @@ + $list + */ + public function __construct( + private array $list, + ) + { + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/arguments/Named.php b/tests/PHPStan/Command/Neon2Attributes/data/arguments/Named.php new file mode 100644 index 00000000000..a2da3561dfb --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/arguments/Named.php @@ -0,0 +1,18 @@ + [AutowiredService::class, []]], if: '%featureToggles.bleedingEdge%')] +final class ConditionalExtension implements ReadWritePropertiesExtension +{ + + public function isAlwaysRead(PropertyReflection $property, string $propertyName): bool + { + return false; + } + + public function isAlwaysWritten(PropertyReflection $property, string $propertyName): bool + { + return false; + } + + public function isInitialized(PropertyReflection $property, string $propertyName): bool + { + return false; + } + + public function __construct( + #[AutowiredParameter(ref: '%checkUninitializedProperties%')] + private bool $flag, + ) + { + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php new file mode 100644 index 00000000000..2958f425ea4 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php @@ -0,0 +1,25 @@ + + */ +final class ConditionalRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php.expected new file mode 100644 index 00000000000..7cb86437597 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ConditionalRule.php.expected @@ -0,0 +1,28 @@ + + */ +#[Conditional(attribute: ['olvlvl\ComposerAttributeCollector\NewExpression' => [AutowiredService::class, []]], if: '%featureToggles.bleedingEdge%')] +final class ConditionalRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/DifferentConditions.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/DifferentConditions.php new file mode 100644 index 00000000000..0201dc00771 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/DifferentConditions.php @@ -0,0 +1,25 @@ + + */ +final class DifferentConditions implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php new file mode 100644 index 00000000000..23f3f84ff0c --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php @@ -0,0 +1,25 @@ + + */ +final class ListRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php.expected new file mode 100644 index 00000000000..e4576c11c04 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/ListRule.php.expected @@ -0,0 +1,28 @@ + + */ +#[Conditional(attribute: ['olvlvl\ComposerAttributeCollector\NewExpression' => [AutowiredService::class, []]], if: ['%featureToggles.bleedingEdge%', 'not(%checkUninitializedProperties%)'])] +final class ListRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php new file mode 100644 index 00000000000..18ef6c4c26d --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php @@ -0,0 +1,25 @@ + + */ +final class NegatedRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php.expected new file mode 100644 index 00000000000..77c742e3d7b --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/NegatedRule.php.expected @@ -0,0 +1,28 @@ + + */ +#[Conditional(attribute: ['olvlvl\ComposerAttributeCollector\NewExpression' => [AutowiredService::class, []]], if: 'not(%featureToggles.bleedingEdge%)')] +final class NegatedRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/RuleSectionConditional.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/RuleSectionConditional.php new file mode 100644 index 00000000000..9a50a1804d5 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/RuleSectionConditional.php @@ -0,0 +1,25 @@ + + */ +final class RuleSectionConditional implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/TaggedAndConditional.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/TaggedAndConditional.php new file mode 100644 index 00000000000..993a4fced74 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/TaggedAndConditional.php @@ -0,0 +1,25 @@ + + */ +final class TaggedAndConditional implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon new file mode 100644 index 00000000000..700169bcb7e --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon @@ -0,0 +1,39 @@ +services: + - + class: Neon2AttributesFixtures\ConditionalPhp80\ConditionalRule + - + class: Neon2AttributesFixtures\ConditionalPhp80\NegatedRule + - + class: Neon2AttributesFixtures\ConditionalPhp80\ListRule + - + class: Neon2AttributesFixtures\ConditionalPhp80\ConditionalExtension + arguments: + flag: %checkUninitializedProperties% + - + class: Neon2AttributesFixtures\ConditionalPhp80\TaggedAndConditional + tags: + - phpstan.rules.rule + - + class: Neon2AttributesFixtures\ConditionalPhp80\DifferentConditions + +rules: + - Neon2AttributesFixtures\ConditionalPhp80\RuleSectionConditional + +conditionalTags: + Neon2AttributesFixtures\ConditionalPhp80\ConditionalRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp80\NegatedRule: + phpstan.rules.rule: not(%featureToggles.bleedingEdge%) + Neon2AttributesFixtures\ConditionalPhp80\ListRule: + phpstan.rules.rule: [%featureToggles.bleedingEdge%, not(%checkUninitializedProperties%)] + Neon2AttributesFixtures\ConditionalPhp80\ConditionalExtension: + phpstan.properties.readWriteExtension: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp80\TaggedAndConditional: + phpstan.collector: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp80\DifferentConditions: + phpstan.rules.rule: %featureToggles.bleedingEdge% + phpstan.collector: %checkUninitializedProperties% + Neon2AttributesFixtures\ConditionalPhp80\RuleSectionConditional: + phpstan.rules.rule: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp80\RegisteredElsewhere: + phpstan.rules.rule: %featureToggles.bleedingEdge% diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon.expected new file mode 100644 index 00000000000..fcfb82b0168 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php80/config.neon.expected @@ -0,0 +1,21 @@ +services: + - + class: Neon2AttributesFixtures\ConditionalPhp80\TaggedAndConditional + tags: + - phpstan.rules.rule + - + class: Neon2AttributesFixtures\ConditionalPhp80\DifferentConditions + +rules: + - Neon2AttributesFixtures\ConditionalPhp80\RuleSectionConditional + +conditionalTags: + Neon2AttributesFixtures\ConditionalPhp80\TaggedAndConditional: + phpstan.collector: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp80\DifferentConditions: + phpstan.rules.rule: %featureToggles.bleedingEdge% + phpstan.collector: %checkUninitializedProperties% + Neon2AttributesFixtures\ConditionalPhp80\RuleSectionConditional: + phpstan.rules.rule: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp80\RegisteredElsewhere: + phpstan.rules.rule: %featureToggles.bleedingEdge% diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalExtension.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalExtension.php new file mode 100644 index 00000000000..354eb3dd453 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalExtension.php @@ -0,0 +1,32 @@ + + */ +final class ConditionalRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalRule.php.expected new file mode 100644 index 00000000000..c005fda4309 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ConditionalRule.php.expected @@ -0,0 +1,28 @@ + + */ +#[Conditional(attribute: new AutowiredService(), if: '%featureToggles.bleedingEdge%')] +final class ConditionalRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/DifferentConditions.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/DifferentConditions.php new file mode 100644 index 00000000000..6efd6bcd691 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/DifferentConditions.php @@ -0,0 +1,25 @@ + + */ +final class DifferentConditions implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php new file mode 100644 index 00000000000..d394dce7643 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php @@ -0,0 +1,25 @@ + + */ +final class ListRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php.expected new file mode 100644 index 00000000000..b5a1752e66b --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/ListRule.php.expected @@ -0,0 +1,28 @@ + + */ +#[Conditional(attribute: new AutowiredService(), if: ['%featureToggles.bleedingEdge%', 'not(%checkUninitializedProperties%)'])] +final class ListRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php new file mode 100644 index 00000000000..d729225528a --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php @@ -0,0 +1,25 @@ + + */ +final class NegatedRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php.expected new file mode 100644 index 00000000000..4f05e77f2a4 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/NegatedRule.php.expected @@ -0,0 +1,28 @@ + + */ +#[Conditional(attribute: new AutowiredService(), if: 'not(%featureToggles.bleedingEdge%)')] +final class NegatedRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/RuleSectionConditional.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/RuleSectionConditional.php new file mode 100644 index 00000000000..505a83ff2d2 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/RuleSectionConditional.php @@ -0,0 +1,25 @@ + + */ +final class RuleSectionConditional implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/TaggedAndConditional.php b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/TaggedAndConditional.php new file mode 100644 index 00000000000..59581d3492f --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/TaggedAndConditional.php @@ -0,0 +1,25 @@ + + */ +final class TaggedAndConditional implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon new file mode 100644 index 00000000000..64cf0cf122a --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon @@ -0,0 +1,39 @@ +services: + - + class: Neon2AttributesFixtures\ConditionalPhp81\ConditionalRule + - + class: Neon2AttributesFixtures\ConditionalPhp81\NegatedRule + - + class: Neon2AttributesFixtures\ConditionalPhp81\ListRule + - + class: Neon2AttributesFixtures\ConditionalPhp81\ConditionalExtension + arguments: + flag: %checkUninitializedProperties% + - + class: Neon2AttributesFixtures\ConditionalPhp81\TaggedAndConditional + tags: + - phpstan.rules.rule + - + class: Neon2AttributesFixtures\ConditionalPhp81\DifferentConditions + +rules: + - Neon2AttributesFixtures\ConditionalPhp81\RuleSectionConditional + +conditionalTags: + Neon2AttributesFixtures\ConditionalPhp81\ConditionalRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp81\NegatedRule: + phpstan.rules.rule: not(%featureToggles.bleedingEdge%) + Neon2AttributesFixtures\ConditionalPhp81\ListRule: + phpstan.rules.rule: [%featureToggles.bleedingEdge%, not(%checkUninitializedProperties%)] + Neon2AttributesFixtures\ConditionalPhp81\ConditionalExtension: + phpstan.properties.readWriteExtension: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp81\TaggedAndConditional: + phpstan.collector: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp81\DifferentConditions: + phpstan.rules.rule: %featureToggles.bleedingEdge% + phpstan.collector: %checkUninitializedProperties% + Neon2AttributesFixtures\ConditionalPhp81\RuleSectionConditional: + phpstan.rules.rule: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp81\RegisteredElsewhere: + phpstan.rules.rule: %featureToggles.bleedingEdge% diff --git a/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon.expected b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon.expected new file mode 100644 index 00000000000..dafef18c777 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/conditional-php81/config.neon.expected @@ -0,0 +1,21 @@ +services: + - + class: Neon2AttributesFixtures\ConditionalPhp81\TaggedAndConditional + tags: + - phpstan.rules.rule + - + class: Neon2AttributesFixtures\ConditionalPhp81\DifferentConditions + +rules: + - Neon2AttributesFixtures\ConditionalPhp81\RuleSectionConditional + +conditionalTags: + Neon2AttributesFixtures\ConditionalPhp81\TaggedAndConditional: + phpstan.collector: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp81\DifferentConditions: + phpstan.rules.rule: %featureToggles.bleedingEdge% + phpstan.collector: %checkUninitializedProperties% + Neon2AttributesFixtures\ConditionalPhp81\RuleSectionConditional: + phpstan.rules.rule: %featureToggles.bleedingEdge% + Neon2AttributesFixtures\ConditionalPhp81\RegisteredElsewhere: + phpstan.rules.rule: %featureToggles.bleedingEdge% diff --git a/tests/PHPStan/Command/Neon2Attributes/data/duplicates/InServicesAndRules.php b/tests/PHPStan/Command/Neon2Attributes/data/duplicates/InServicesAndRules.php new file mode 100644 index 00000000000..42d4d4b3fa8 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/duplicates/InServicesAndRules.php @@ -0,0 +1,25 @@ + + */ +final class InServicesAndRules implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/duplicates/InTwoFiles.php b/tests/PHPStan/Command/Neon2Attributes/data/duplicates/InTwoFiles.php new file mode 100644 index 00000000000..15aae9717a7 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/duplicates/InTwoFiles.php @@ -0,0 +1,9 @@ + + */ +final class MyRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/rules-section/MyRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/rules-section/MyRule.php.expected new file mode 100644 index 00000000000..026bf345a9c --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/rules-section/MyRule.php.expected @@ -0,0 +1,27 @@ + + */ +#[RegisteredRule(level: 0)] +final class MyRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon b/tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon new file mode 100644 index 00000000000..45342095ac2 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon @@ -0,0 +1,5 @@ +parameters: + foo: true + +rules: + - Neon2AttributesFixtures\RulesSection\MyRule diff --git a/tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon.expected b/tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon.expected new file mode 100644 index 00000000000..6265e73643f --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/rules-section/config.neon.expected @@ -0,0 +1,2 @@ +parameters: + foo: true diff --git a/tests/PHPStan/Command/Neon2Attributes/data/tags/PartiallyTaggedExtension.php b/tests/PHPStan/Command/Neon2Attributes/data/tags/PartiallyTaggedExtension.php new file mode 100644 index 00000000000..8393cd027de --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/tags/PartiallyTaggedExtension.php @@ -0,0 +1,31 @@ + + */ +final class TaggedRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedRule.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedRule.php.expected new file mode 100644 index 00000000000..084d3acde96 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/tags/TaggedRule.php.expected @@ -0,0 +1,27 @@ + + */ +#[AutowiredService] +final class TaggedRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/tags/UnknownTag.php b/tests/PHPStan/Command/Neon2Attributes/data/tags/UnknownTag.php new file mode 100644 index 00000000000..f0c8f59837b --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/tags/UnknownTag.php @@ -0,0 +1,9 @@ + + */ +final class TagsWithValues implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return []; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/WithSetup.php b/tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/WithSetup.php new file mode 100644 index 00000000000..db655dd33a3 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/unsupported-keys/WithSetup.php @@ -0,0 +1,12 @@ + Date: Mon, 2 Nov 2026 21:36:05 +0000 Subject: [PATCH 05/14] Filter #[AutowiredParameter] values like NEON service arguments Class::CONST, ::CONST, Enum::Case and @service strings become constants and references, also inside the value of a referenced parameter. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- phpstan-baseline.neon | 8 ++- .../AttributeServicesRegistrar.php | 48 +++++++++++++---- .../AttributeServicesRegistrarTest.php | 54 +++++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 65597d8ada8..51a6d0e58d5 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -256,7 +256,13 @@ 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/AttributeServices/AttributeServicesRegistrar.php + + - + rawMessage: 'Call to static method filterArguments() of internal class Nette\DI\Helpers from outside its root namespace Nette.' + identifier: staticMethod.internalClass + count: 2 path: src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php - diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php index 44a276b11c8..7919f737a0b 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -11,7 +11,6 @@ use Nette\Neon\Neon; use Nette\PhpGenerator\Dumper; use Nette\PhpGenerator\PhpLiteral; -use Nette\Utils\Strings; use olvlvl\ComposerAttributeCollector\TargetClass; use olvlvl\ComposerAttributeCollector\TargetMethodParameter; use PHPStan\Collectors\RegistryFactory; @@ -243,16 +242,47 @@ private static function expandCondition(ContainerBuilder $builder, string $condi public static function processConstructorParameters(ContainerBuilder $builder, string $className, ServiceDefinition $definition, array $constructorParameters): void { foreach ($constructorParameters[strtolower($className)] ?? [] as $autowiredParameter) { - $ref = $autowiredParameter->attribute->ref; - if ($ref === null) { - $argument = self::createDeferredParameter($builder, '%' . Helpers::escape($autowiredParameter->name) . '%'); - } elseif (Strings::match($ref, '#^@[\w\\\\]+$#D') !== null) { - $argument = new Reference(substr($ref, 1)); - } else { - $argument = self::createDeferredParameter($builder, $ref); + $ref = $autowiredParameter->attribute->ref ?? '%' . Helpers::escape($autowiredParameter->name) . '%'; + $definition->setArgument($autowiredParameter->name, self::createArgument($builder, $ref)); + } + } + + /** + * Treats the reference like a NEON service argument: Nette\DI\Helpers::filterArguments() turns + * `Class::CONST`, `::CONST`, `Enum::Case` and `@service` strings into constants and references, + * also inside the value of a referenced parameter. A parameter with such strings is inlined + * like Nette does in NEON, other parameters are looked up at runtime. + * + * @return mixed + */ + private static function createArgument(ContainerBuilder $builder, string $ref) + { + $deferred = self::createDeferredParameter($builder, $ref); + if (is_string($deferred)) { + return Helpers::filterArguments([$deferred])[0]; + } + + $expanded = Helpers::expand($ref, $builder->parameters); + $filtered = Helpers::filterArguments([$expanded])[0]; + + return self::isFiltered($expanded, $filtered) ? $filtered : $deferred; + } + + private static function isFiltered(mixed $value, mixed $filtered): bool + { + if (is_string($value)) { + return !is_string($filtered); + } + if (!is_array($value) || !is_array($filtered)) { + return false; + } + foreach ($value as $key => $item) { + if (self::isFiltered($item, $filtered[$key])) { + return true; } - $definition->setArgument($autowiredParameter->name, $argument); } + + return false; } /** diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php index 195eec32feb..f51004ab0f7 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesRegistrarTest.php @@ -3,9 +3,16 @@ namespace PHPStan\DependencyInjection\AttributeServices; use Nette\DI\ContainerBuilder; +use Nette\DI\Definitions\Reference; use Nette\InvalidArgumentException; +use Nette\PhpGenerator\PhpLiteral; +use olvlvl\ComposerAttributeCollector\TargetMethodParameter; +use PHPStan\DependencyInjection\AutowiredParameter; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_map; +use function is_array; +use function strtolower; class AttributeServicesRegistrarTest extends TestCase { @@ -49,6 +56,51 @@ public function testMissingParameter(): void AttributeServicesRegistrar::isConditionMet($this->createBuilder(), 'not(%toggles.missing%)'); } + /** + * @return iterable + */ + public static function dataConstructorParameter(): iterable + { + // like NEON service arguments: Class::CONST, ::CONST, Enum::Case and @service strings + // in the value are turned into constants and references + yield ['PHPStan\\Rules\\LazyRegistry::RULE_TAG', new PhpLiteral('PHPStan\\Rules\\LazyRegistry::RULE_TAG')]; + yield ['::PHP_EOL', new PhpLiteral('PHP_EOL')]; + yield ['%constants%', [new PhpLiteral('PHPStan\\Rules\\LazyRegistry::RULE_TAG'), 'plain', ['nested' => new Reference('service')]]]; + yield ['%toggles.enabled%', new PhpLiteral('$this->getParameter(\'toggles\')[\'enabled\']')]; + yield ['%plainList%', new PhpLiteral('$this->getParameter(\'plainList\')')]; + yield ['plain', 'plain']; + } + + #[DataProvider('dataConstructorParameter')] + public function testConstructorParameter(string $ref, mixed $expectedArgument): void + { + $builder = $this->createBuilder(); + $definition = $builder->addDefinition('foo')->setType(self::class); + AttributeServicesRegistrar::processConstructorParameters($builder, self::class, $definition, [ + strtolower(self::class) => [new TargetMethodParameter(new AutowiredParameter($ref), self::class, 'value', '__construct')], + ]); + + $this->assertSame(self::describeArgument(['value' => $expectedArgument]), self::describeArgument($definition->getCreator()->arguments)); + } + + /** + * assertEquals() would consider a PhpLiteral equal to a string with the same contents. + */ + private static function describeArgument(mixed $argument): mixed + { + if ($argument instanceof PhpLiteral) { + return 'literal ' . $argument; + } + if ($argument instanceof Reference) { + return 'reference ' . $argument->getValue(); + } + if (is_array($argument)) { + return array_map(static fn ($item) => self::describeArgument($item), $argument); + } + + return $argument; + } + private function createBuilder(): ContainerBuilder { $builder = new ContainerBuilder(); @@ -58,6 +110,8 @@ private function createBuilder(): ContainerBuilder 'reference' => '%enabled%', 'enabledList' => [true, '%enabled%'], 'mixedList' => ['%enabled%', '%disabled%'], + 'constants' => ['PHPStan\\Rules\\LazyRegistry::RULE_TAG', 'plain', ['nested' => '@service']], + 'plainList' => ['a', 'b'], 'toggles' => [ 'enabled' => true, 'disabled' => false, From 6f15d6b947b5ad3379f63ef1eb791f450910aae1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 2 Nov 2026 22:01:46 +0000 Subject: [PATCH 06/14] Accept static factory methods in #[AutowiredService] and #[NonAutowiredService] Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../AttributeServicesRegistrar.php | 17 ++++++++++---- src/DependencyInjection/AutowiredService.php | 3 +++ .../NonAutowiredService.php | 3 +++ .../AttributeServicesIntegrationTest.php | 8 +++++++ .../services/DiscoveredStaticallyCreated.php | 22 +++++++++++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredStaticallyCreated.php diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php index 7919f737a0b..527f5a36bdd 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -35,6 +35,7 @@ use function is_string; use function preg_split; use function sprintf; +use function str_starts_with; use function strcasecmp; use function strtolower; use function substr; @@ -73,8 +74,7 @@ public static function registerServices(ContainerBuilder $builder, AttributeTarg ->setAutowired($attribute->as); if ($attribute->factory !== null) { - [$ref, $method] = explode('::', $attribute->factory); - $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); + $definition->setFactory(self::createFactory($attribute->factory)); } $registeredClasses[] = $class->name; @@ -101,8 +101,7 @@ public static function registerServices(ContainerBuilder $builder, AttributeTarg ->setAutowired(false); if ($attribute->factory !== null) { - [$ref, $method] = explode('::', $attribute->factory); - $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); + $definition->setFactory(self::createFactory($attribute->factory)); } $registeredClasses[] = $class->name; @@ -247,6 +246,16 @@ public static function processConstructorParameters(ContainerBuilder $builder, s } } + /** + * '@service::method' calls a method of another service, 'Class::method' a static method. + */ + private static function createFactory(string $factory): Statement + { + [$class, $method] = explode('::', $factory, 2); + + return new Statement([str_starts_with($class, '@') ? new Reference(substr($class, 1)) : $class, $method]); + } + /** * Treats the reference like a NEON service argument: Nette\DI\Helpers::filterArguments() turns * `Class::CONST`, `::CONST`, `Enum::Case` and `@service` strings into constants and references, diff --git a/src/DependencyInjection/AutowiredService.php b/src/DependencyInjection/AutowiredService.php index d117d87f967..6370349b9bb 100644 --- a/src/DependencyInjection/AutowiredService.php +++ b/src/DependencyInjection/AutowiredService.php @@ -11,6 +11,9 @@ * Pass autoTag: false for services whose implemented interfaces * must not lead to the associated tags. * + * The factory is a method of another service like '@App\MyFactory::create', + * or a static method like 'App\MyFactory::create'. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. * diff --git a/src/DependencyInjection/NonAutowiredService.php b/src/DependencyInjection/NonAutowiredService.php index be41184b330..61d0da2a633 100644 --- a/src/DependencyInjection/NonAutowiredService.php +++ b/src/DependencyInjection/NonAutowiredService.php @@ -7,6 +7,9 @@ /** * Registers a non-autowired named service in the DI container. * + * The factory is a method of another service like '@App\MyFactory::create', + * or a static method like 'App\MyFactory::create'. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. * diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php index c707e950bd1..217f1409f15 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -11,6 +11,7 @@ use AttributeServicesFixtures\DiscoveredRuleLevelEight; use AttributeServicesFixtures\DiscoveredRuleLevelThree; use AttributeServicesFixtures\DiscoveredService; +use AttributeServicesFixtures\DiscoveredStaticallyCreated; use AttributeServicesFixtures\DiscoveredValue; use AttributeServicesFixtures\DiscoveredValueFactory; use PHPStan\Collectors\RegistryFactory; @@ -84,6 +85,13 @@ public function testDiscoveredNamedService(): void $this->assertInstanceOf(DiscoveredNamedService::class, $container->getService('attributeServicesFixtures.named')); } + public function testDiscoveredServicesCreatedByStaticFactory(): void + { + $container = self::createContainer(); + $this->assertInstanceOf(DiscoveredStaticallyCreated::class, $container->getService('attributeServicesFixtures.staticallyCreated')); + $this->assertInstanceOf(DiscoveredStaticallyCreated::class, $container->getService('attributeServicesFixtures.staticallyCreatedNonAutowired')); + } + public function testDiscoveredNonAutowiredService(): void { $container = self::createContainer(); diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredStaticallyCreated.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredStaticallyCreated.php new file mode 100644 index 00000000000..5e00ee9b4cf --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredStaticallyCreated.php @@ -0,0 +1,22 @@ + Date: Mon, 2 Nov 2026 22:01:46 +0000 Subject: [PATCH 07/14] Migrate static factories and services created by their implementation class class: FooInterface with factory: Foo becomes #[AutowiredService(as: FooInterface::class)] on Foo. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../Neon2AttributesMigrator.php | 40 +++++++++++++------ .../Neon2AttributesMigratorTest.php | 5 ++- .../data/factory/Implementation.php.expected | 11 +++++ .../data/factory/StaticWithArguments.php | 13 ++++++ .../factory/StaticallyCreated.php.expected | 12 ++++++ .../Neon2Attributes/data/factory/config.neon | 3 ++ .../data/factory/config.neon.expected | 7 +--- 7 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/Implementation.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/StaticWithArguments.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/factory/StaticallyCreated.php.expected diff --git a/src/Command/Neon2Attributes/Neon2AttributesMigrator.php b/src/Command/Neon2Attributes/Neon2AttributesMigrator.php index bc71cedfe32..e6b177807fe 100644 --- a/src/Command/Neon2Attributes/Neon2AttributesMigrator.php +++ b/src/Command/Neon2Attributes/Neon2AttributesMigrator.php @@ -290,29 +290,43 @@ private function createDefinition(string $section, NeonArrayItemNode $item): Ser } $factory = null; } + $autowired = $value['autowired'] ?? true; + if ($factory !== null && is_string($factoryClass) && !str_starts_with($factoryClass, '@') && !str_contains($factoryClass, '::') && is_a($factoryClass, $class, true)) { + // `class: FooInterface` with `factory: Foo` or `factory: Foo(...)` creates Foo autowired as FooInterface + if (array_key_exists('autowired', $value)) { + return sprintf('autowired of a service created by %s', $factoryClass); + } + if ($factory instanceof Entity) { + if (array_key_exists('arguments', $value)) { + return 'arguments in two places'; + } + $arguments = $factory->attributes; + } + $autowired = ltrim($class, '\\'); + $class = $factoryClass; + $factory = null; + } if ($factory !== null && !self::isServiceMethodFactory($factory)) { $factoryValue = $factory instanceof Entity ? $factory->value : $factory; if (!is_string($factoryValue)) { return 'factory is not a method of another service'; } if (!str_starts_with($factoryValue, '@')) { - if (str_contains($factoryValue, '::')) { - return 'static factory method'; - } - if (is_a($factoryValue, $class, true)) { - return sprintf('service type differs from the created class %s', $factoryValue); + if (!str_contains($factoryValue, '::')) { + return 'created by another class'; } - return 'created by another class'; - } - if (str_contains($factoryValue, '%')) { + if ($factory instanceof Entity && $factory->attributes !== []) { + return 'arguments of a static factory method'; + } + $factory = ltrim($factoryValue, '\\'); + } elseif (str_contains($factoryValue, '%')) { return 'factory service is chosen by a parameter'; - } - if (!str_contains($factoryValue, '::')) { + } elseif (!str_contains($factoryValue, '::')) { return 'factory is a reference to another service'; + } else { + return 'factory is not a method of another service'; } - - return 'factory is not a method of another service'; } if ($factory instanceof Entity) { $factory = $factory->value; @@ -328,7 +342,7 @@ private function createDefinition(string $section, NeonArrayItemNode $item): Ser } } - return new ServiceDefinition($class, $name, $arguments, $factory, $value['autowired'] ?? true, array_values($tags), null); + return new ServiceDefinition(ltrim($class, '\\'), $name, $arguments, $factory, $autowired, array_values($tags), null); } /** diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php index add131a77f7..896a76ad4fb 100644 --- a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php @@ -55,14 +55,15 @@ public static function dataMigrate(): iterable yield 'factory' => ['factory', 80100, [ 'Created: AutowiredService', 'CreatedFactory: AutowiredService', + 'StaticallyCreated: AutowiredService', 'ArrayCreated: AutowiredService', + 'Implementation: AutowiredService', 'TypedInterface: AutowiredService', 'SameClass: AutowiredService', 'SameClassWithArguments: AutowiredService, AutowiredParameter', - 'kept StaticallyCreated: static factory method', 'kept ParameterCreated: factory service is chosen by a parameter', 'kept Aliased: factory is a reference to another service', - 'kept CreatedInterface: service type differs from the created class Neon2AttributesFixtures\\Factory\\Implementation', + 'kept StaticWithArguments: arguments of a static factory method', ]]; yield 'autowired' => ['autowired', 80100, [ diff --git a/tests/PHPStan/Command/Neon2Attributes/data/factory/Implementation.php.expected b/tests/PHPStan/Command/Neon2Attributes/data/factory/Implementation.php.expected new file mode 100644 index 00000000000..6683e9ea04d --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/factory/Implementation.php.expected @@ -0,0 +1,11 @@ + Date: Mon, 2 Nov 2026 22:08:55 +0000 Subject: [PATCH 08/14] Map third-party extension interfaces to tags through #[ExtensionInterface] and the extensionInterfaces section Attribute services implementing these interfaces get the tag automatically, and conditionalTags and the tag validation accept the configured tags. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../AttributeServicesDiscoverer.php | 9 +-- .../AttributeServicesRegistrar.php | 6 +- .../AutowiredAttributeServicesExtension.php | 5 +- .../ConditionalTagsExtension.php | 45 ++++++++--- .../ExtensionInterface.php | 7 ++ .../ExtensionInterfacesExtension.php | 31 +++++++ .../ValidateServiceTagsExtension.php | 81 ++++++++++++++++--- .../AttributeServicesIntegrationTest.php | 58 +++++++++++-- .../BadExtensionInterface.php | 11 --- .../AttributeMappedExtension.php | 11 +++ .../AttributeMappedService.php | 11 +++ .../ConditionallyTaggedService.php | 8 ++ .../NeonMappedExtension.php | 8 ++ .../extensionInterfaces/NeonMappedService.php | 11 +++ .../WronglyTaggedService.php | 8 ++ .../extensionInterfaces.neon | 20 +++++ .../extensionInterfacesCoreTag.neon | 5 ++ .../extensionInterfacesMissing.neon | 2 + .../extensionInterfacesWrongTag.neon | 7 ++ 19 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 src/DependencyInjection/ExtensionInterfacesExtension.php delete mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterface/BadExtensionInterface.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterfaces/AttributeMappedExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterfaces/AttributeMappedService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterfaces/ConditionallyTaggedService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterfaces/NeonMappedExtension.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterfaces/NeonMappedService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterfaces/WronglyTaggedService.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/extensionInterfaces.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/extensionInterfacesCoreTag.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/extensionInterfacesMissing.neon create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/extensionInterfacesWrongTag.neon diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php index f61f0889e65..0a782c2206d 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php @@ -73,6 +73,7 @@ final class AttributeServicesDiscoverer RegisteredCollector::class, GenerateFactory::class, Conditional::class, + ExtensionInterface::class, ]; /** @@ -292,14 +293,6 @@ private function checkDisallowedAttribute(string $attributeName, string $classNa return; } - if ($lowerAttributeName === strtolower(ExtensionInterface::class)) { - $this->errors[] = sprintf( - 'Attribute #[ExtensionInterface] on %s is not supported in directories from the attributeServicesDirectories section - third-party extension interfaces are not supported.', - $className, - ); - return; - } - if ($lowerAttributeName === strtolower(AutowiredExtensions::class)) { $this->errors[] = sprintf( 'Attribute #[AutowiredExtensions] on a constructor parameter of class %s is not supported in directories from the attributeServicesDirectories section.', diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php index 527f5a36bdd..9af6091ee1a 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -22,7 +22,6 @@ use PHPStan\DependencyInjection\NonAutowiredService; use PHPStan\DependencyInjection\RegisteredCollector; use PHPStan\DependencyInjection\RegisteredRule; -use PHPStan\DependencyInjection\ValidateServiceTagsExtension; use PHPStan\Rules\LazyRegistry; use PHPStan\ShouldNotHappenException; use ReflectionClass; @@ -50,9 +49,10 @@ final class AttributeServicesRegistrar { /** + * @param array $interfaceTagMapping tags added to the services implementing the interfaces * @return list the registered classes */ - public static function registerServices(ContainerBuilder $builder, AttributeTargetsProvider $targets, ?int $level): array + public static function registerServices(ContainerBuilder $builder, AttributeTargetsProvider $targets, ?int $level, array $interfaceTagMapping): array { $registeredClasses = []; $constructorParameters = self::collectConstructorParameters($targets); @@ -84,7 +84,7 @@ public static function registerServices(ContainerBuilder $builder, AttributeTarg continue; } - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + foreach ($interfaceTagMapping as $interface => $tag) { if (!$reflection->implementsInterface($interface)) { continue; } diff --git a/src/DependencyInjection/AutowiredAttributeServicesExtension.php b/src/DependencyInjection/AutowiredAttributeServicesExtension.php index 20843285a44..c1de8a32fa0 100644 --- a/src/DependencyInjection/AutowiredAttributeServicesExtension.php +++ b/src/DependencyInjection/AutowiredAttributeServicesExtension.php @@ -30,10 +30,13 @@ public function loadConfiguration(): void $config = $this->getConfig(); $builder = $this->getContainerBuilder(); $targets = AttributeTargetsProvider::create(); - $registeredClasses = AttributeServicesRegistrar::registerServices($builder, $targets, $config->level); + $registeredClasses = AttributeServicesRegistrar::registerServices($builder, $targets, $config->level, ValidateServiceTagsExtension::getExtendedInterfaceTagMapping($this->compiler)); // ResultCacheManager treats these like the classes from the services section of the config $discoveredClasses = []; + foreach ($targets->findTargetClasses(ExtensionInterface::class) as $extensionInterface) { + $registeredClasses[] = $extensionInterface->name; + } foreach ($registeredClasses as $registeredClass) { if (!$targets->isDiscoveredClass($registeredClass)) { continue; diff --git a/src/DependencyInjection/ConditionalTagsExtension.php b/src/DependencyInjection/ConditionalTagsExtension.php index d1f909d11e3..9f35eee1627 100644 --- a/src/DependencyInjection/ConditionalTagsExtension.php +++ b/src/DependencyInjection/ConditionalTagsExtension.php @@ -6,12 +6,14 @@ use Nette; use Nette\DI\CompilerExtension; use Nette\DI\Definitions\Statement; +use Nette\DI\InvalidConfigurationException; use Nette\Schema\Expect; use Override; use PHPStan\ShouldNotHappenException; -use function array_fill_keys; +use function array_keys; use function array_values; use function count; +use function in_array; use function is_array; use function sprintf; @@ -19,18 +21,41 @@ final class ConditionalTagsExtension extends CompilerExtension { + /** + * Accepts any tag, the tags of third-party extension interfaces are known only + * after all sections are processed. loadConfiguration() checks them. + */ #[Override] public function getConfigSchema(): Nette\Schema\Schema { - $tags = array_values(ValidateServiceTagsExtension::getInterfaceTagMapping()); - - return Expect::arrayOf(Expect::structure( - array_fill_keys($tags, Expect::anyOf( - Expect::bool(), - Expect::type(Statement::class), - Expect::listOf(Expect::anyOf(Expect::bool(), Expect::type(Statement::class))), - )), - )->min(1)); + return Expect::arrayOf(Expect::arrayOf(Expect::anyOf( + Expect::bool(), + Expect::type(Statement::class), + Expect::listOf(Expect::anyOf(Expect::bool(), Expect::type(Statement::class))), + ), 'string')->min(1)); + } + + #[Override] + public function loadConfiguration(): void + { + /** @var array> $config */ + $config = $this->config; + $tags = array_values(ValidateServiceTagsExtension::getExtendedInterfaceTagMapping($this->compiler)); + foreach ($config as $type => $typeTags) { + foreach (array_keys($typeTags) as $tag) { + if (in_array($tag, $tags, true)) { + continue; + } + + throw new InvalidConfigurationException(sprintf( + "Unexpected item '%s › %s › %s', tags of extension interfaces are expected. Map the interface of a custom tag in the %s section.", + $this->name, + $type, + $tag, + ExtensionInterfacesExtension::NAME, + )); + } + } } #[Override] diff --git a/src/DependencyInjection/ExtensionInterface.php b/src/DependencyInjection/ExtensionInterface.php index 3558c77e610..80bfba41423 100644 --- a/src/DependencyInjection/ExtensionInterface.php +++ b/src/DependencyInjection/ExtensionInterface.php @@ -11,13 +11,20 @@ * get the tag automatically. ValidateServiceTagsExtension checks that manually * tagged services implement the associated interface. * + * Third-party interfaces get the attribute in a directory from the + * attributeServicesDirectories section, or are listed in the extensionInterfaces + * section of the configuration file instead. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class ExtensionInterface { + /** @api */ public function __construct(public string $tag) { } diff --git a/src/DependencyInjection/ExtensionInterfacesExtension.php b/src/DependencyInjection/ExtensionInterfacesExtension.php new file mode 100644 index 00000000000..90bcd8c5108 --- /dev/null +++ b/src/DependencyInjection/ExtensionInterfacesExtension.php @@ -0,0 +1,31 @@ + */ @@ -51,6 +56,70 @@ public static function getInterfaceTagMapping(): array return self::$interfaceTagMapping = $mapping; } + /** + * PHPStan's own extension interfaces plus the third-party ones, marked with #[ExtensionInterface] + * in the attributeServicesDirectories section or listed in the extensionInterfaces section. + * + * @return array + * @throws InvalidConfigurationException + */ + public static function getExtendedInterfaceTagMapping(Compiler $compiler): array + { + $configured = []; + foreach ($compiler->getExtensions(ExtensionInterfacesExtension::class) as $extension) { + $config = $extension->getConfig(); + if (!is_array($config)) { + continue; + } + $configured += $config; + } + + return self::createInterfaceTagMapping($configured); + } + + /** + * @param array $configured the extensionInterfaces section + * @return array + * @throws InvalidConfigurationException + */ + public static function createInterfaceTagMapping(array $configured): array + { + $mapping = self::getInterfaceTagMapping(); + $entries = []; + foreach (AttributeTargetsProvider::create()->findTargetClasses(ExtensionInterface::class) as $class) { + $entries[] = [$class->name, $class->attribute->tag, sprintf('#[ExtensionInterface] on %s', $class->name)]; + } + foreach ($configured as $interface => $tag) { + if (!is_string($interface) || !is_string($tag)) { + continue; + } + $entries[] = [$interface, $tag, sprintf('the %s section', ExtensionInterfacesExtension::NAME)]; + } + + $tagInterfaces = array_flip($mapping); + foreach ($entries as [$interface, $tag, $source]) { + if (!interface_exists($interface)) { + throw new InvalidConfigurationException(sprintf('Interface %s from %s does not exist.', $interface, $source)); + } + $interface = (new ReflectionClass($interface))->getName(); + if (array_key_exists($interface, $mapping)) { + if ($mapping[$interface] === $tag) { + continue; + } + + throw new InvalidConfigurationException(sprintf('Interface %s is mapped to tag %s, %s maps it to %s.', $interface, $mapping[$interface], $source, $tag)); + } + if (array_key_exists($tag, $tagInterfaces)) { + throw new InvalidConfigurationException(sprintf('Tag %s is mapped to interface %s, %s maps it to %s.', $tag, $tagInterfaces[$tag], $source, $interface)); + } + + $mapping[$interface] = $tag; + $tagInterfaces[$tag] = $interface; + } + + return $mapping; + } + /** * Deliberately runs in afterCompile() rather than beforeCompile(): tags are added by other * extensions' beforeCompile() - ConditionalTagsExtension, and any extension registered from a @@ -65,13 +134,7 @@ public static function getInterfaceTagMapping(): array public function afterCompile(ClassType $class): void { $builder = $this->getContainerBuilder(); - $mapping = self::getInterfaceTagMapping(); - $mappingCount = count($mapping); - $flippedMapping = array_flip($mapping); - - if (count($flippedMapping) !== $mappingCount) { - throw new ShouldNotHappenException('A tag is mapped to multiple interfaces'); - } + $flippedMapping = array_flip(self::getExtendedInterfaceTagMapping($this->compiler)); foreach ($builder->getDefinitions() as $definition) { /** @var class-string|null $className */ diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php index 217f1409f15..5564a3e2e84 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -14,19 +14,27 @@ use AttributeServicesFixtures\DiscoveredStaticallyCreated; use AttributeServicesFixtures\DiscoveredValue; use AttributeServicesFixtures\DiscoveredValueFactory; +use AttributeServicesFixtures\ExtensionInterfaces\AttributeMappedService; +use AttributeServicesFixtures\ExtensionInterfaces\ConditionallyTaggedService; +use AttributeServicesFixtures\ExtensionInterfaces\NeonMappedService; +use Nette\DI\InvalidConfigurationException; use PHPStan\Collectors\RegistryFactory; use PHPStan\DependencyInjection\Container; use PHPStan\DependencyInjection\ContainerFactory; use PHPStan\DependencyInjection\DerivativeContainerFactory; +use PHPStan\DependencyInjection\MissingImplementedInterfaceInServiceWithTagException; use PHPStan\Rules\LazyRegistry; use PHPStan\Rules\Properties\ReadWritePropertiesExtension; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; +use Throwable; use function array_filter; +use function array_map; use function dirname; use function md5; use function mkdir; +use function sort; use function strtr; use function sys_get_temp_dir; use function uniqid; @@ -125,6 +133,51 @@ public function testDiscoveredConditional(): void $this->assertCount(1, $container->findServiceNamesByType(DiscoveredConditionalService::class)); } + public function testThirdPartyExtensionInterfaces(): void + { + $container = (new ContainerFactory(__DIR__))->create(self::createTmpDir(), [__DIR__ . '/extensionInterfaces.neon'], [], [dirname(__DIR__, 4)]); + + $attributeMapped = $container->getServicesByTag('attributeServicesFixtures.attributeMapped'); + $this->assertSame([AttributeMappedService::class], array_map(static fn (object $service): string => $service::class, $attributeMapped)); + + $neonMapped = array_map(static fn (object $service): string => $service::class, $container->getServicesByTag('attributeServicesFixtures.neonMapped')); + sort($neonMapped); + $this->assertSame([ConditionallyTaggedService::class, NeonMappedService::class], $neonMapped); + } + + /** + * @return iterable, string}> + */ + public static function dataExtensionInterfacesErrors(): iterable + { + yield [ + 'extensionInterfacesWrongTag.neon', + MissingImplementedInterfaceInServiceWithTagException::class, + 'Service of type AttributeServicesFixtures\\ExtensionInterfaces\\WronglyTaggedService with tag attributeServicesFixtures.neonMapped does not implement interface AttributeServicesFixtures\\ExtensionInterfaces\\NeonMappedExtension.', + ]; + yield [ + 'extensionInterfacesCoreTag.neon', + InvalidConfigurationException::class, + 'Tag phpstan.rules.rule is mapped to interface PHPStan\\Rules\\Rule, the extensionInterfaces section maps it to AttributeServicesFixtures\\ExtensionInterfaces\\NeonMappedExtension.', + ]; + yield [ + 'extensionInterfacesMissing.neon', + InvalidConfigurationException::class, + 'Interface AttributeServicesFixtures\\ExtensionInterfaces\\Missing from the extensionInterfaces section does not exist.', + ]; + } + + /** + * @param class-string $exceptionClass + */ + #[DataProvider('dataExtensionInterfacesErrors')] + public function testExtensionInterfacesErrors(string $neonFile, string $exceptionClass, string $expectedMessage): void + { + $this->expectException($exceptionClass); + $this->expectExceptionMessage($expectedMessage); + (new ContainerFactory(__DIR__))->create(self::createTmpDir(), [__DIR__ . '/' . $neonFile], [], [dirname(__DIR__, 4)]); + } + public function testDiscoveredCollector(): void { $container = self::createContainer(); @@ -191,11 +244,6 @@ public static function dataErrors(): iterable 'Attribute #[ContainerExtension] on class AttributeServicesFixtures\ContainerExtension\BadCompilerExtension is not supported in directories from the attributeServicesDirectories section - the list of compiler extensions is fixed before the section is processed. Register the class in the `extensions` section of the configuration file instead.', ]; - yield [ - 'extensionInterface.neon', - 'Attribute #[ExtensionInterface] on AttributeServicesFixtures\ExtensionInterface\BadExtensionInterface is not supported in directories from the attributeServicesDirectories section - third-party extension interfaces are not supported.', - ]; - yield [ 'autowiredExtensionsParam.neon', 'Attribute #[AutowiredExtensions] on a constructor parameter of class AttributeServicesFixtures\AutowiredExtensions\BadAutowiredExtensionsService is not supported in directories from the attributeServicesDirectories section.', diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterface/BadExtensionInterface.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterface/BadExtensionInterface.php deleted file mode 100644 index d775b8c95b2..00000000000 --- a/tests/PHPStan/DependencyInjection/AttributeServices/data/extensionInterface/BadExtensionInterface.php +++ /dev/null @@ -1,11 +0,0 @@ - Date: Mon, 2 Nov 2026 22:08:55 +0000 Subject: [PATCH 09/14] Migrate the extensionInterfaces section to #[ExtensionInterface] Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../Neon2AttributesMigrator.php | 50 ++++++++++++++++--- src/Command/Neon2AttributesCommand.php | 30 +++++++++++ .../Neon2AttributesMigratorTest.php | 20 ++++++-- .../ConditionalService.php | 8 +++ .../ConditionalService.php.expected | 12 +++++ .../data/extension-interfaces/MyExtension.php | 8 +++ .../MyExtension.php.expected | 11 ++++ .../extension-interfaces/TaggedService.php | 8 +++ .../TaggedService.php.expected | 11 ++++ .../extension-interfaces/UntaggedService.php | 8 +++ .../UntaggedService.php.expected | 11 ++++ .../data/extension-interfaces/config.neon | 16 ++++++ .../extension-interfaces/config.neon.expected | 2 + 13 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/ConditionalService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/ConditionalService.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/MyExtension.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/MyExtension.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/TaggedService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/TaggedService.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/UntaggedService.php create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/UntaggedService.php.expected create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/config.neon create mode 100644 tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/config.neon.expected diff --git a/src/Command/Neon2Attributes/Neon2AttributesMigrator.php b/src/Command/Neon2Attributes/Neon2AttributesMigrator.php index e6b177807fe..f4c05d80027 100644 --- a/src/Command/Neon2Attributes/Neon2AttributesMigrator.php +++ b/src/Command/Neon2Attributes/Neon2AttributesMigrator.php @@ -11,7 +11,8 @@ use Nette\Neon\TokenStream as NeonTokenStream; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\DependencyInjection\ValidateServiceTagsExtension; +use PHPStan\DependencyInjection\ExtensionInterface; +use PHPStan\DependencyInjection\ExtensionInterfacesExtension; use PHPStan\File\CouldNotReadFileException; use PHPStan\File\FileReader; use PHPStan\Neon\Printer as NeonPrinter; @@ -77,8 +78,9 @@ public function __construct( * @param list $attributeServicesDirectories * @param int $phpVersionId the lowest PHP version the attributes must work on, for #[Conditional] * @param list $otherServiceNames names of services defined outside of $configFiles, an entry with the same name overrides them + * @param array $interfaceTagMapping all extension interfaces with their tags, including the ones from the extensionInterfaces sections of $configFiles */ - public function migrate(array $configFiles, array $attributeServicesDirectories, int $phpVersionId, array $otherServiceNames): Neon2AttributesResult + public function migrate(array $configFiles, array $attributeServicesDirectories, int $phpVersionId, array $otherServiceNames, array $interfaceTagMapping): Neon2AttributesResult { // reflection reports real paths, a package from a path repository is a symlink in vendor/ $directories = array_map(static fn (string $directory): string => rtrim(strtr(realpath($directory) ?: $directory, '\\', '/'), '/'), $attributeServicesDirectories); @@ -89,6 +91,8 @@ public function migrate(array $configFiles, array $attributeServicesDirectories, $entries = []; /** @var array}> $conditionalTags by lowercase class name */ $conditionalTags = []; + /** @var list $extensionInterfaces */ + $extensionInterfaces = []; foreach ($configFiles as $file) { try { $code = FileReader::read($file); @@ -120,6 +124,10 @@ public function migrate(array $configFiles, array $attributeServicesDirectories, } $conditionalTags[strtolower($class)] = ['file' => $file, 'item' => $item, 'tags' => $tags]; } + } elseif ($section === ExtensionInterfacesExtension::NAME) { + foreach ($sectionItem->value->items as $item) { + $extensionInterfaces[] = ['file' => $file, 'item' => $item]; + } } } } @@ -181,7 +189,7 @@ public function migrate(array $configFiles, array $attributeServicesDirectories, } $conditional = $conditionalTags[$lowerClass] ?? null; - $classAttributes = $this->createClassAttributes($entry['section'], $definition, $reflection, $conditional === null ? null : $conditional['tags'], $phpVersionId); + $classAttributes = $this->createClassAttributes($entry['section'], $definition, $reflection, $conditional === null ? null : $conditional['tags'], $phpVersionId, $interfaceTagMapping); if (is_string($classAttributes)) { $kept[] = new KeptService(KeptService::CATEGORY_KEPT, $reflection->getName(), $classAttributes, $entry['file'], $line); continue; @@ -200,6 +208,33 @@ public function migrate(array $configFiles, array $attributeServicesDirectories, $converted[] = new ConvertedService($classAttributes->reflection->getName(), $attributes, $entry['file']); } + foreach ($extensionInterfaces as $entry) { + $interface = $entry['item']->key !== null ? $entry['item']->key->toValue() : null; + $tag = $entry['item']->value->toValue(); + $line = $entry['item']->startLine ?? 0; + if (!is_string($interface) || !is_string($tag)) { + continue; + } + if (!interface_exists($interface)) { + $kept[] = new KeptService(KeptService::CATEGORY_KEPT, $interface, 'interface not found', $entry['file'], $line); + continue; + } + $reflection = new ReflectionClass($interface); + $fileName = $reflection->getFileName(); + if ($fileName === false || !self::isInDirectories(strtr($fileName, '\\', '/'), $directories)) { + $kept[] = new KeptService(KeptService::CATEGORY_OUTSIDE, $reflection->getName(), null, $entry['file'], $line); + continue; + } + if (count($reflection->getAttributes(ExtensionInterface::class)) > 0) { + $kept[] = new KeptService(KeptService::CATEGORY_KEPT, $reflection->getName(), 'already has #[ExtensionInterface]', $entry['file'], $line); + continue; + } + + $classEdits[$fileName][] = new ClassAttributes($reflection, new AttributeSpec('ExtensionInterface', ['tag' => $tag]), []); + $removedItems[$entry['file']][] = $entry['item']; + $converted[] = new ConvertedService($reflection->getName(), ['ExtensionInterface'], $entry['file']); + } + $changedFiles = []; foreach ($removedItems as $file => $items) { $newCode = $this->removeItems($files[$file], $items); @@ -383,9 +418,10 @@ private function createFactoryDefinition(?string $name, array $value): ServiceDe /** * @param ReflectionClass $reflection * @param array|null $conditionalTags + * @param array $interfaceTagMapping * @return ClassAttributes|string the attributes, or why the definition cannot be converted */ - private function createClassAttributes(string $section, ServiceDefinition $definition, ReflectionClass $reflection, ?array $conditionalTags, int $phpVersionId): ClassAttributes|string + private function createClassAttributes(string $section, ServiceDefinition $definition, ReflectionClass $reflection, ?array $conditionalTags, int $phpVersionId, array $interfaceTagMapping): ClassAttributes|string { if (!$reflection->isInstantiable() && $definition->generatedFactory === null && $definition->factory === null) { return 'not an instantiable class'; @@ -415,7 +451,7 @@ private function createClassAttributes(string $section, ServiceDefinition $defin } $interfaceTags = []; - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + foreach ($interfaceTagMapping as $interface => $tag) { if (!$reflection->implementsInterface($interface)) { continue; } @@ -424,10 +460,10 @@ private function createClassAttributes(string $section, ServiceDefinition $defin $interfaceTags = array_values(array_unique($interfaceTags)); sort($interfaceTags); - $knownTags = array_values(ValidateServiceTagsExtension::getInterfaceTagMapping()); + $knownTags = array_values($interfaceTagMapping); foreach ($definition->tags as $tag) { if (!in_array($tag, $knownTags, true)) { - return sprintf('custom tag %s has no attribute equivalent', $tag); + return sprintf('tag %s is not mapped to an interface in the %s section', $tag, ExtensionInterfacesExtension::NAME); } } diff --git a/src/Command/Neon2AttributesCommand.php b/src/Command/Neon2AttributesCommand.php index 0a6481af65b..0dee685c12d 100644 --- a/src/Command/Neon2AttributesCommand.php +++ b/src/Command/Neon2AttributesCommand.php @@ -13,7 +13,9 @@ use PHPStan\DependencyInjection\AttributeServices\AttributeTargetsProvider; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Conditional; +use PHPStan\DependencyInjection\ExtensionInterfacesExtension; use PHPStan\DependencyInjection\NonAutowiredService; +use PHPStan\DependencyInjection\ValidateServiceTagsExtension; use PHPStan\File\CouldNotReadFileException; use PHPStan\File\FileHelper; use PHPStan\File\FileReader; @@ -220,6 +222,7 @@ private function migrate( $directories, $phpVersion->getVersionId(), self::findOtherServiceNames(array_values(array_diff($allConfigFiles, $configFiles))), + ValidateServiceTagsExtension::createInterfaceTagMapping(self::findExtensionInterfaces($allConfigFiles)), ); /** @var RelativePathHelper $relativePathHelper */ @@ -312,6 +315,33 @@ private function migrate( return 0; } + /** + * The merged extensionInterfaces sections of the config files. + * + * @param list $configFiles + * @return array + */ + private static function findExtensionInterfaces(array $configFiles): array + { + $extensionInterfaces = []; + foreach ($configFiles as $file) { + if (!str_ends_with($file, '.neon')) { + continue; + } + try { + $config = Neon::decode(FileReader::read($file)); + } catch (CouldNotReadFileException | NeonException) { + continue; + } + if (!is_array($config) || !is_array($config[ExtensionInterfacesExtension::NAME] ?? null)) { + continue; + } + $extensionInterfaces += $config[ExtensionInterfacesExtension::NAME]; + } + + return $extensionInterfaces; + } + /** * Services defined by PHPStan's own attributes and by the config files that are not migrated. * diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php index 896a76ad4fb..62538b44c70 100644 --- a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Command\Neon2Attributes; +use PHPStan\DependencyInjection\ValidateServiceTagsExtension; use PHPStan\File\FileReader; use PHPStan\Testing\PHPStanTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -26,7 +27,7 @@ class Neon2AttributesMigratorTest extends PHPStanTestCase { /** - * @return iterable, 3?: list}> + * @return iterable, 3?: list, 4?: array}> */ public static function dataMigrate(): iterable { @@ -44,7 +45,7 @@ public static function dataMigrate(): iterable 'UntaggedExtension: AutowiredService', 'TaggedRule: AutowiredService', 'kept PartiallyTaggedExtension: tags do not match the implemented extension interfaces, they would add phpstan.constants.alwaysUsedClassConstantsExtension', - 'kept UnknownTag: custom tag my.own.tag has no attribute equivalent', + 'kept UnknownTag: tag my.own.tag is not mapped to an interface in the extensionInterfaces section', ]]; yield 'named service' => ['named-service', 80100, [ @@ -148,6 +149,14 @@ public static function dataMigrate(): iterable 'kept JunitFormatter: overrides service errorFormatter.junit', ], ['errorFormatter.junit']]; + yield 'extension interfaces' => ['extension-interfaces', 80100, [ + 'TaggedService: AutowiredService', + 'UntaggedService: AutowiredService', + 'ConditionalService: Conditional', + 'MyExtension: ExtensionInterface', + 'outside PHPStan\\Rules\\Rule', + ], [], ['Neon2AttributesFixtures\\ExtensionInterfaces\\MyExtension' => 'my.extension']]; + yield 'imports' => ['imports', 80100, [ 'SortedImports: AutowiredService', 'ExistingImport: AutowiredService', @@ -159,9 +168,10 @@ public static function dataMigrate(): iterable /** * @param list $expectedReport * @param list $otherServiceNames + * @param array $extensionInterfaces */ #[DataProvider('dataMigrate')] - public function testMigrate(string $case, int $phpVersionId, array $expectedReport, array $otherServiceNames = []): void + public function testMigrate(string $case, int $phpVersionId, array $expectedReport, array $otherServiceNames = [], array $extensionInterfaces = []): void { $directory = __DIR__ . '/data/' . $case; $configFiles = glob($directory . '/*.neon'); @@ -170,7 +180,7 @@ public function testMigrate(string $case, int $phpVersionId, array $expectedRepo $attributeServicesDirectory = $case === 'outside-directories' ? $directory . '/Inside' : $directory; $migrator = self::getContainer()->getByType(Neon2AttributesMigrator::class); - $result = $migrator->migrate($configFiles, [$attributeServicesDirectory], $phpVersionId, $otherServiceNames); + $result = $migrator->migrate($configFiles, [$attributeServicesDirectory], $phpVersionId, $otherServiceNames, ValidateServiceTagsExtension::getInterfaceTagMapping() + $extensionInterfaces); $namespace = 'Neon2AttributesFixtures\\' . str_replace('-', '', ucwords($case, '-')) . '\\'; $report = []; @@ -208,7 +218,7 @@ public function testSymlinkedDirectory(): void try { $configFiles = glob($link . '/*.neon'); $this->assertNotFalse($configFiles); - $result = self::getContainer()->getByType(Neon2AttributesMigrator::class)->migrate($configFiles, [$link], 80100, []); + $result = self::getContainer()->getByType(Neon2AttributesMigrator::class)->migrate($configFiles, [$link], 80100, [], ValidateServiceTagsExtension::getInterfaceTagMapping()); } finally { unlink($link); } diff --git a/tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/ConditionalService.php b/tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/ConditionalService.php new file mode 100644 index 00000000000..5e2469e3f39 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/extension-interfaces/ConditionalService.php @@ -0,0 +1,8 @@ + Date: Mon, 2 Nov 2026 22:12:51 +0000 Subject: [PATCH 10/14] Use the single type in #[AutowiredService(as:)] as the service type Like class: Interface, factory: Implementation in NEON, the service is no longer autowired as the implementation class too. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../AttributeServices/AttributeServicesRegistrar.php | 10 ++++++++-- .../AutowiredExtensionsExtension.php | 11 ++++------- src/DependencyInjection/AutowiredService.php | 4 ++++ .../AttributeServicesIntegrationTest.php | 9 +++++++++ .../data/services/DiscoveredImplementation.php | 11 +++++++++++ .../data/services/DiscoveredInterface.php | 8 ++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredImplementation.php create mode 100644 tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredInterface.php diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php index 9af6091ee1a..1b39c02ace9 100644 --- a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -66,15 +66,21 @@ public static function registerServices(ContainerBuilder $builder, AttributeTarg } foreach (self::findTargetClasses($targets, AutowiredService::class, $conditionalTargets) as [$class, $tagged]) { - $reflection = new ReflectionClass($class->name); $attribute = $class->attribute; + // a single type in `as` becomes the service type like `class: Interface, factory: Implementation` in NEON, + // otherwise Nette would autowire the service as the implementation class too + $type = is_string($attribute->as) ? $attribute->as : $class->name; + $reflection = new ReflectionClass($type); + $definition = $builder->addDefinition($attribute->name) - ->setType($class->name) + ->setType($type) ->setAutowired($attribute->as); if ($attribute->factory !== null) { $definition->setFactory(self::createFactory($attribute->factory)); + } elseif ($type !== $class->name) { + $definition->setFactory($class->name); } $registeredClasses[] = $class->name; diff --git a/src/DependencyInjection/AutowiredExtensionsExtension.php b/src/DependencyInjection/AutowiredExtensionsExtension.php index b4223364626..75a05c87767 100644 --- a/src/DependencyInjection/AutowiredExtensionsExtension.php +++ b/src/DependencyInjection/AutowiredExtensionsExtension.php @@ -87,15 +87,12 @@ public function beforeCompile(): void if (!$definition instanceof ServiceDefinition) { continue; } - $className = $definition->getType(); - if ($className === null) { - continue; - } - // only wire arguments into definitions that actually invoke the constructor, - // not aliases of other services (factory: @otherService) or method factories + // not aliases of other services (factory: @otherService) or method factories; + // the constructor is the one of the factory class, which can differ from the type (class: Interface, factory: Implementation) $creatorEntity = $definition->getCreator()->getEntity(); - if ($creatorEntity !== null && (!is_string($creatorEntity) || strcasecmp($creatorEntity, $className) !== 0)) { + $className = $creatorEntity ?? $definition->getType(); + if (!is_string($className)) { continue; } diff --git a/src/DependencyInjection/AutowiredService.php b/src/DependencyInjection/AutowiredService.php index 6370349b9bb..810e78fb267 100644 --- a/src/DependencyInjection/AutowiredService.php +++ b/src/DependencyInjection/AutowiredService.php @@ -14,6 +14,10 @@ * The factory is a method of another service like '@App\MyFactory::create', * or a static method like 'App\MyFactory::create'. * + * A single type in `as` becomes the type of the service, like `class: Interface, factory: Implementation` + * in NEON: the service is autowired only as that type and gets the tags of the interfaces that type implements. + * A list of types restricts autowiring without changing the type of the service. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. * diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php index 5564a3e2e84..0c3c160c121 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -6,6 +6,8 @@ use AttributeServicesFixtures\DiscoveredConditionalRule; use AttributeServicesFixtures\DiscoveredConditionalRuleNotMet; use AttributeServicesFixtures\DiscoveredConditionalService; +use AttributeServicesFixtures\DiscoveredImplementation; +use AttributeServicesFixtures\DiscoveredInterface; use AttributeServicesFixtures\DiscoveredNamedService; use AttributeServicesFixtures\DiscoveredNonAutowiredService; use AttributeServicesFixtures\DiscoveredRuleLevelEight; @@ -93,6 +95,13 @@ public function testDiscoveredNamedService(): void $this->assertInstanceOf(DiscoveredNamedService::class, $container->getService('attributeServicesFixtures.named')); } + public function testDiscoveredServiceAutowiredAsInterface(): void + { + $container = self::createContainer(); + $this->assertInstanceOf(DiscoveredImplementation::class, $container->getByType(DiscoveredInterface::class)); + $this->assertSame([], $container->findServiceNamesByType(DiscoveredImplementation::class)); + } + public function testDiscoveredServicesCreatedByStaticFactory(): void { $container = self::createContainer(); diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredImplementation.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredImplementation.php new file mode 100644 index 00000000000..e0bda41951c --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredImplementation.php @@ -0,0 +1,11 @@ + Date: Tue, 3 Nov 2026 09:31:05 +0000 Subject: [PATCH 11/14] Fix attribute services and neon2attributes tests on PHP 7.4, PHP 8.6 and Windows Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .gitattributes | 1 + build/baseline-pre-8.0.neon | 36 +++++++++++++++++++ .../Neon2AttributesMigratorTest.php | 10 ++++-- ...tributeServicesDirectoriesResolverTest.php | 2 +- .../AttributeServicesDiscovererTest.php | 3 ++ .../AttributeServicesIntegrationTest.php | 5 +-- 6 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index ba520f3418f..237724b4da9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,6 @@ *.stub linguist-language=PHP tests/PHPStan/Command/ErrorFormatter/data/WindowsNewlines.php eol=crlf +tests/PHPStan/Command/Neon2Attributes/data/** text eol=lf .github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file diff --git a/build/baseline-pre-8.0.neon b/build/baseline-pre-8.0.neon index d24f1f80b9b..0e94aa40013 100644 --- a/build/baseline-pre-8.0.neon +++ b/build/baseline-pre-8.0.neon @@ -36,6 +36,42 @@ parameters: count: 1 path: ../src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php + - + rawMessage: 'Call to an undefined method ReflectionClass::getAttributes().' + identifier: method.notFound + count: 2 + path: ../src/Command/Neon2Attributes/Neon2AttributesMigrator.php + + - + rawMessage: 'Call to an undefined method ReflectionParameter::getAttributes().' + identifier: method.notFound + count: 1 + path: ../src/Command/Neon2Attributes/Neon2AttributesMigrator.php + + - + rawMessage: 'Call to method is() of internal class Symfony\Polyfill\Php80\PhpToken from outside its root namespace Symfony.' + identifier: method.internalClass + count: 5 + path: ../src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php + + - + rawMessage: 'Access to property $text of internal class Symfony\Polyfill\Php80\PhpToken from outside its root namespace Symfony.' + identifier: property.internalClass + count: 2 + path: ../src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php + + - + rawMessage: 'Call to method getTokenName() of internal class Symfony\Polyfill\Php80\PhpToken from outside its root namespace Symfony.' + identifier: method.internalClass + count: 1 + path: ../src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php + + - + rawMessage: 'Call to method isIgnorable() of internal class Symfony\Polyfill\Php80\PhpToken from outside its root namespace Symfony.' + identifier: method.internalClass + count: 1 + path: ../src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php + - rawMessage: 'Class AttributeServicesFixtures\DiscoveredConditionalRuleNotMet not found.' identifier: class.notFound diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php index 62538b44c70..4d82c1d23da 100644 --- a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\RequiresPhp; use function array_diff; use function array_keys; +use function array_map; use function array_values; use function glob; use function implode; @@ -16,6 +17,7 @@ use function sort; use function sprintf; use function str_replace; +use function strtr; use function symlink; use function sys_get_temp_dir; use function ucwords; @@ -194,8 +196,12 @@ public function testMigrate(string $case, int $phpVersionId, array $expectedRepo } $this->assertSame($expectedReport, $report); - $changedFiles = $result->getChangedFiles(); - $files = [...$configFiles, ...self::findPhpFiles($directory)]; + // reflection reports the PHP files with backslashes on Windows + $changedFiles = []; + foreach ($result->getChangedFiles() as $file => $code) { + $changedFiles[strtr($file, '\\', '/')] = $code; + } + $files = array_map(static fn (string $file): string => strtr($file, '\\', '/'), [...$configFiles, ...self::findPhpFiles($directory)]); foreach ($files as $file) { $expectedFile = $file . '.expected'; $expected = is_file($expectedFile) ? FileReader::read($expectedFile) : FileReader::read($file); diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php index cb5c0dfc25d..12d7f7925b5 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php @@ -94,7 +94,7 @@ public function testDirectoryOutsideComposerProjectsIsHashed(): void $this->assertCount(1, $resolved->directories); $this->assertNull($resolved->directories[0]->packageName); - $this->assertArrayHasKey(__DIR__ . '/data/psr4Mismatch/WellPlaced.php', $resolved->directories[0]->cacheKeyComponent); + $this->assertArrayHasKey(strtr(__DIR__, '\\', '/') . '/data/psr4Mismatch/WellPlaced.php', $resolved->directories[0]->cacheKeyComponent); } public function testProjectOwnDirectory(): void diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php index 275cbff1ce5..8d702e48bbe 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php @@ -5,6 +5,7 @@ use AttributeServicesFixtures\Psr4Mismatch\WellPlaced; use olvlvl\ComposerAttributeCollector\TargetClass; use PHPStan\DependencyInjection\AutowiredService; +use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; @@ -27,6 +28,7 @@ class AttributeServicesDiscovererTest extends TestCase * autoload, so a file declaring another class must not be autoloaded more than once. */ #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function testFileDeclaringAnotherClassThanItsPath(): void { $targets = $this->discover('/data/psr4Mismatch', 'AttributeServicesFixtures\\Psr4Mismatch\\'); @@ -41,6 +43,7 @@ public function testFileDeclaringAnotherClassThanItsPath(): void * Only files that can reference PHPStan's DI attributes have their classes loaded. */ #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function testFileMentioningAttributesOnlyInCommentIsNotLoaded(): void { $targets = $this->discover('/data/prefilter', 'AttributeServicesFixtures\\PHPStan\\Prefilter\\'); diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php index 0c3c160c121..96c914400a8 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -34,6 +34,7 @@ use function array_filter; use function array_map; use function dirname; +use function get_class; use function md5; use function mkdir; use function sort; @@ -147,9 +148,9 @@ public function testThirdPartyExtensionInterfaces(): void $container = (new ContainerFactory(__DIR__))->create(self::createTmpDir(), [__DIR__ . '/extensionInterfaces.neon'], [], [dirname(__DIR__, 4)]); $attributeMapped = $container->getServicesByTag('attributeServicesFixtures.attributeMapped'); - $this->assertSame([AttributeMappedService::class], array_map(static fn (object $service): string => $service::class, $attributeMapped)); + $this->assertSame([AttributeMappedService::class], array_map(static fn (object $service): string => get_class($service), $attributeMapped)); - $neonMapped = array_map(static fn (object $service): string => $service::class, $container->getServicesByTag('attributeServicesFixtures.neonMapped')); + $neonMapped = array_map(static fn (object $service): string => get_class($service), $container->getServicesByTag('attributeServicesFixtures.neonMapped')); sort($neonMapped); $this->assertSame([ConditionallyTaggedService::class, NeonMappedService::class], $neonMapped); } From 2aef3d6b3eccc763e14fabeffe59e5d6a457e19e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 3 Nov 2026 09:31:05 +0000 Subject: [PATCH 12/14] Map PhpParser\NodeVisitor to its tag in the extensionInterfaces section of config.neon Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- conf/config.neon | 4 ++++ src/Command/Neon2AttributesCommand.php | 7 ++++++- src/DependencyInjection/AutowiredExtensionsExtension.php | 6 +++--- src/DependencyInjection/ValidateServiceTagsExtension.php | 8 ++------ src/Parser/RichParser.php | 2 -- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/conf/config.neon b/conf/config.neon index ff60f350616..e62bd4dc7b5 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -271,6 +271,10 @@ parameters: autowiredAttributeServices: level: null +# vendor interfaces cannot carry the #[PHPStan\DependencyInjection\ExtensionInterface] attribute +extensionInterfaces: + PhpParser\NodeVisitor: phpstan.parser.richParserNodeVisitor + # autowired services are registered with the help of attributes # like #[PHPStan\DependencyInjection\AutowiredService] or #[PHPStan\DependencyInjection\GenerateFactory], # optionally wrapped in #[PHPStan\DependencyInjection\Conditional] diff --git a/src/Command/Neon2AttributesCommand.php b/src/Command/Neon2AttributesCommand.php index 0dee685c12d..2bd3e2db78a 100644 --- a/src/Command/Neon2AttributesCommand.php +++ b/src/Command/Neon2AttributesCommand.php @@ -34,6 +34,7 @@ use function array_map; use function array_values; use function count; +use function dirname; use function getcwd; use function implode; use function is_array; @@ -222,7 +223,11 @@ private function migrate( $directories, $phpVersion->getVersionId(), self::findOtherServiceNames(array_values(array_diff($allConfigFiles, $configFiles))), - ValidateServiceTagsExtension::createInterfaceTagMapping(self::findExtensionInterfaces($allConfigFiles)), + ValidateServiceTagsExtension::createInterfaceTagMapping(self::findExtensionInterfaces([ + // PHPStan's own config.neon is not among allConfigFiles + dirname(__DIR__, 2) . '/conf/config.neon', + ...$allConfigFiles, + ])), ); /** @var RelativePathHelper $relativePathHelper */ diff --git a/src/DependencyInjection/AutowiredExtensionsExtension.php b/src/DependencyInjection/AutowiredExtensionsExtension.php index 75a05c87767..db26c9b3e46 100644 --- a/src/DependencyInjection/AutowiredExtensionsExtension.php +++ b/src/DependencyInjection/AutowiredExtensionsExtension.php @@ -40,7 +40,7 @@ public static function getCollectionServiceName(string $extensionInterfaceName): public function loadConfiguration(): void { $builder = $this->getContainerBuilder(); - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + foreach (ValidateServiceTagsExtension::getExtendedInterfaceTagMapping($this->compiler) as $interface => $tag) { $builder->addDefinition(self::getCollectionServiceName($interface)) ->setType(LazyExtensionsCollection::class) ->setArgument('tagName', $tag) @@ -53,7 +53,7 @@ public function beforeCompile(): void { require_once __DIR__ . '/../../vendor/attributes.php'; $builder = $this->getContainerBuilder(); - $mapping = ValidateServiceTagsExtension::getInterfaceTagMapping(); + $mapping = ValidateServiceTagsExtension::getExtendedInterfaceTagMapping($this->compiler); $parametersByClass = []; foreach (Attributes::findTargetMethodParameters(AutowiredExtensions::class) as $parameter) { @@ -61,7 +61,7 @@ public function beforeCompile(): void throw new ShouldNotHappenException(sprintf('Attribute #[AutowiredExtensions] is only supported on constructor parameters, found on %s::%s() $%s.', $parameter->class, $parameter->method, $parameter->name)); } if (!array_key_exists($parameter->attribute->of, $mapping)) { - throw new ShouldNotHappenException(sprintf('Interface %s referenced by #[AutowiredExtensions] on %s::__construct() $%s is not marked with the #[ExtensionInterface] attribute.', $parameter->attribute->of, $parameter->class, $parameter->name)); + throw new ShouldNotHappenException(sprintf('Interface %s referenced by #[AutowiredExtensions] on %s::__construct() $%s is not marked with the #[ExtensionInterface] attribute nor listed in the extensionInterfaces section.', $parameter->attribute->of, $parameter->class, $parameter->name)); } $nativeType = null; diff --git a/src/DependencyInjection/ValidateServiceTagsExtension.php b/src/DependencyInjection/ValidateServiceTagsExtension.php index 7b0ae415a41..657f62fc299 100644 --- a/src/DependencyInjection/ValidateServiceTagsExtension.php +++ b/src/DependencyInjection/ValidateServiceTagsExtension.php @@ -8,9 +8,7 @@ use Nette\PhpGenerator\ClassType; use olvlvl\ComposerAttributeCollector\Attributes; use Override; -use PhpParser\NodeVisitor; use PHPStan\DependencyInjection\AttributeServices\AttributeTargetsProvider; -use PHPStan\Parser\RichParser; use PHPStan\ShouldNotHappenException; use ReflectionClass; use function array_flip; @@ -30,6 +28,7 @@ final class ValidateServiceTagsExtension extends CompilerExtension /** * PHPStan's own extension interfaces, derived from the #[ExtensionInterface] attribute above each of them. + * Vendor interfaces like PhpParser\NodeVisitor are in the extensionInterfaces section of conf/config.neon. * * @return array */ @@ -41,10 +40,7 @@ public static function getInterfaceTagMapping(): array require_once __DIR__ . '/../../vendor/attributes.php'; - $mapping = [ - // vendor interface - cannot carry the #[ExtensionInterface] attribute - NodeVisitor::class => RichParser::VISITOR_SERVICE_TAG, - ]; + $mapping = []; foreach (Attributes::findTargetClasses(ExtensionInterface::class) as $class) { // the attribute is not repeatable but the collector does not validate that if (array_key_exists($class->name, $mapping)) { diff --git a/src/Parser/RichParser.php b/src/Parser/RichParser.php index 6ea1fc17a3c..b52c6ff16b5 100644 --- a/src/Parser/RichParser.php +++ b/src/Parser/RichParser.php @@ -41,8 +41,6 @@ final class RichParser implements Parser { - public const VISITOR_SERVICE_TAG = 'phpstan.parser.richParserNodeVisitor'; - private const PHPDOC_TAG_REGEX = '(@(?:[a-z][a-z0-9-\\\\]+:)?[a-z][a-z0-9-\\\\]*+)'; private const PHPDOC_DOCTRINE_TAG_REGEX = '(@[a-z_\\\\][a-z0-9_\:\\\\]*[a-z_][a-z0-9_]*)'; From 0fa1ea44dbb7219f1b276910e953c16e29104831 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 3 Nov 2026 09:37:14 +0000 Subject: [PATCH 13/14] Run attribute services discoverer tests in the test process A separate process re-includes the files of the parent process, which triggers deprecations on PHP 8.6, and PHPUnit versions disagree on how to disable that. Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../AttributeServices/AttributeServicesDiscovererTest.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php index 8d702e48bbe..a34613b856b 100644 --- a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDiscovererTest.php @@ -5,9 +5,7 @@ use AttributeServicesFixtures\Psr4Mismatch\WellPlaced; use olvlvl\ComposerAttributeCollector\TargetClass; use PHPStan\DependencyInjection\AutowiredService; -use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RequiresPhp; -use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use function array_map; use function defined; @@ -27,8 +25,6 @@ class AttributeServicesDiscovererTest extends TestCase * Composer's PSR-4 loader includes the file of the class name derived from the path on every * autoload, so a file declaring another class must not be autoloaded more than once. */ - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function testFileDeclaringAnotherClassThanItsPath(): void { $targets = $this->discover('/data/psr4Mismatch', 'AttributeServicesFixtures\\Psr4Mismatch\\'); @@ -42,8 +38,6 @@ public function testFileDeclaringAnotherClassThanItsPath(): void /** * Only files that can reference PHPStan's DI attributes have their classes loaded. */ - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function testFileMentioningAttributesOnlyInCommentIsNotLoaded(): void { $targets = $this->discover('/data/prefilter', 'AttributeServicesFixtures\\PHPStan\\Prefilter\\'); From 22dfeb9a45c4815c806012422c22c2bd42e06c63 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 3 Nov 2026 09:40:52 +0000 Subject: [PATCH 14/14] Remove the directory symlink with rmdir() on Windows in Neon2AttributesMigratorTest Amp-Thread-ID: https://ampcode.com/threads/T-01a19cd4-be31-75b4-bd15-67786eff5983 --- .../Neon2Attributes/Neon2AttributesMigratorTest.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php index 4d82c1d23da..1d33bedc6a3 100644 --- a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesMigratorTest.php @@ -14,6 +14,7 @@ use function glob; use function implode; use function is_file; +use function rmdir; use function sort; use function sprintf; use function str_replace; @@ -23,6 +24,7 @@ use function ucwords; use function uniqid; use function unlink; +use const DIRECTORY_SEPARATOR; #[RequiresPhp('>= 8.0.0')] class Neon2AttributesMigratorTest extends PHPStanTestCase @@ -226,7 +228,12 @@ public function testSymlinkedDirectory(): void $this->assertNotFalse($configFiles); $result = self::getContainer()->getByType(Neon2AttributesMigrator::class)->migrate($configFiles, [$link], 80100, [], ValidateServiceTagsExtension::getInterfaceTagMapping()); } finally { - unlink($link); + // a symlink to a directory is removed with rmdir() on Windows + if (DIRECTORY_SEPARATOR === '\\') { + rmdir($link); + } else { + unlink($link); + } } $this->assertSame([], $result->getKeptServices());