diff --git a/bin/phpstan b/bin/phpstan index 91c25266a1d..bd1d59cbda4 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\WorkerCommand; use PHPStan\Internal\ComposerHelper; use PHPStan\Turbo\TurboExtensionEnabler; @@ -127,5 +128,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()); $application->run(); })(); diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 953bab24371..8639253129e 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -209,12 +209,14 @@ parametersSchema: analysedPathsFromConfig: listOf(string()) usedLevel: string() cliAutoloadFile: schema(string(), nullable()) + attributeServicesDirectories: listOf(string()) # internal - editor mode singleReflectionFile: schema(string(), nullable()) singleReflectionInsteadOfFile: schema(string(), nullable()) expandRelativePaths: + - '[attributeServicesDirectories][]' - '[parameters][paths][]' - '[parameters][excludePaths][]' - '[parameters][excludePaths][analyse][]' diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fef664d8b33..9ac8ed89850 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -208,7 +208,7 @@ parameters: rawMessage: 'Call to static method escape() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass count: 1 - path: src/DependencyInjection/AutowiredAttributeServicesExtension.php + path: src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php - rawMessage: 'Call to static method expand() of internal class Nette\DI\Helpers from outside its root namespace Nette.' diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 47840e7dba8..6bb0f511a63 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -83,6 +83,7 @@ final class ResultCacheManager * @param string[] $analysedPaths * @param string[] $analysedPathsFromConfig * @param string[] $composerAutoloaderProjectPaths + * @param string[] $attributeServicesDirectories * @param string[] $bootstrapFiles * @param string[] $scanFiles * @param string[] $scanDirectories @@ -108,6 +109,8 @@ public function __construct( #[AutowiredParameter] private array $composerAutoloaderProjectPaths, #[AutowiredParameter] + private array $attributeServicesDirectories, + #[AutowiredParameter] private string $usedLevel, #[AutowiredParameter] private ?string $cliAutoloadFile, @@ -1367,6 +1370,15 @@ private function streamArrayVarExportToHandle($handle, string $file, array $valu */ private function changedPackagesProvideContainerClass(?array $projectConfig, array $changedPackagesLookup): bool { + // Directories from the attributeServicesDirectories section register services straight from + // their classes, so a changed package owning one of them can affect the analysis of every file. + foreach ($this->attributeServicesDirectories as $attributeServicesDirectory) { + $package = $this->packageDependencyResolver->resolveDirectoryPackage($attributeServicesDirectory); + if ($package !== null && array_key_exists($package, $changedPackagesLookup)) { + return true; + } + } + // Extensions registered directly in the project config (services:/rules:) or via an included // extension neon file: resolve each service class to the package that owns its file. if ($projectConfig !== null) { diff --git a/src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php b/src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php new file mode 100644 index 00000000000..525cb522cf8 --- /dev/null +++ b/src/Command/Neon2Attributes/Neon2AttributesAnalyzer.php @@ -0,0 +1,517 @@ +analyzeRuleEntry($rule, $entryIndex, $conversions, $skipped); + $entryIndex++; + } + } + + $services = $decoded['services'] ?? []; + if (is_array($services)) { + $entryIndex = 0; + foreach ($services as $key => $service) { + $this->analyzeServiceEntry(is_string($key) ? $key : null, $service, $entryIndex, $conversions, $skipped); + $entryIndex++; + } + } + + return new Neon2AttributesPlan( + $conversions, + $skipped, + $this->collectDirectoriesToDeclare($neonFile, $conversions), + ); + } + + /** + * @param mixed $rule + * @param list $conversions + * @param list $skipped + */ + private function analyzeRuleEntry($rule, int $entryIndex, array &$conversions, array &$skipped): void + { + if (!is_string($rule)) { + $skipped[] = new SkippedEntry('rules', sprintf('entry #%d', $entryIndex), 'The entry is not a plain class name.'); + return; + } + + $file = $this->getConvertibleClassFile($rule, 'rules', $skipped); + if ($file === null) { + return; + } + + $conversions[] = new ServiceConversion( + 'rules', + $entryIndex, + $rule, + $file, + '#[RegisteredRule(level: 0)]', + [], + ['PHPStan\DependencyInjection\RegisteredRule'], + ); + } + + /** + * @param mixed $service + * @param list $conversions + * @param list $skipped + */ + private function analyzeServiceEntry(?string $name, $service, int $entryIndex, array &$conversions, array &$skipped): void + { + $description = $name ?? sprintf('entry #%d', $entryIndex); + + if (is_string($service)) { + $service = ['class' => $service]; + } + if (!is_array($service)) { + $skipped[] = new SkippedEntry('services', $description, 'The service definition is not a plain class name or a map.'); + return; + } + + if ($name === null && isset($service['class']) && is_string($service['class'])) { + $description = $service['class']; + } + + foreach (array_keys($service) as $key) { + if (!is_string($key) || !in_array($key, self::SUPPORTED_SERVICE_KEYS, true)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The service definition uses `%s` which cannot be expressed with an attribute.', is_string($key) ? $key : (string) $key)); + return; + } + } + + $class = $service['class'] ?? null; + $factory = $service['factory'] ?? null; + if ($class === null && is_string($factory) && !str_contains($factory, '::')) { + $class = $factory; + $factory = null; + } + if (!is_string($class)) { + $skipped[] = new SkippedEntry('services', $description, 'The service class cannot be determined statically.'); + return; + } + $description = $name ?? $class; + + $factoryArgument = null; + if ($factory !== null) { + if (!is_string($factory) || preg_match('#^@[\w\\\\]+::\w+$#', $factory) !== 1) { + $skipped[] = new SkippedEntry('services', $description, 'Only a `@service::method` factory can be expressed with an attribute.'); + return; + } + $factoryArgument = $factory; + } + + $file = $this->getConvertibleClassFile($class, 'services', $skipped, $description); + if ($file === null) { + return; + } + + $reflection = new ReflectionClass($class); /** @phpstan-ignore argument.type */ + + $parameterAttributes = $this->buildParameterAttributes($service['arguments'] ?? [], $reflection, $description, $skipped); + if ($parameterAttributes === null) { + return; + } + + $tags = $service['tags'] ?? []; + if (!is_array($tags)) { + $skipped[] = new SkippedEntry('services', $description, 'The tags cannot be determined statically.'); + return; + } + foreach ($tags as $tag) { + if (is_string($tag)) { + continue; + } + + $skipped[] = new SkippedEntry('services', $description, 'Tags with attributes cannot be expressed with an attribute.'); + return; + } + + $autowired = $service['autowired'] ?? true; + + if ($tags === [LazyRegistry::RULE_TAG] || $tags === [RegistryFactory::COLLECTOR_TAG]) { + if ($name !== null) { + $skipped[] = new SkippedEntry('services', $description, 'A named rule or collector service cannot be expressed with an attribute.'); + return; + } + if ($factoryArgument !== null) { + $skipped[] = new SkippedEntry('services', $description, 'A rule or collector with a factory cannot be expressed with an attribute.'); + return; + } + + $attribute = $tags === [LazyRegistry::RULE_TAG] + ? 'RegisteredRule' + : 'RegisteredCollector'; + $conversions[] = new ServiceConversion( + 'services', + $entryIndex, + $class, + $file, + sprintf('#[%s(level: 0)]', $attribute), + $parameterAttributes, + $this->collectUseImports($attribute, $parameterAttributes), + ); + return; + } + + $derivableTags = []; + foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + if (!$reflection->implementsInterface($interface)) { + continue; + } + + $derivableTags[] = $tag; + } + + foreach ($tags as $tag) { + if (in_array($tag, $derivableTags, true)) { + continue; + } + + $skipped[] = new SkippedEntry('services', $description, sprintf('The tag %s cannot be derived from the implemented interfaces.', $tag)); + return; + } + + $sortedTags = array_values($tags); + sort($sortedTags); + $sortedDerivable = $derivableTags; + sort($sortedDerivable); + + if ($sortedTags === $sortedDerivable) { + $autoTag = true; + } elseif (count($tags) === 0) { + $autoTag = false; + } else { + $skipped[] = new SkippedEntry('services', $description, 'The class implements more tagged extension interfaces than the entry declares as tags.'); + return; + } + + if ($autowired === false) { + if ($name === null) { + $skipped[] = new SkippedEntry('services', $description, 'A non-autowired service without a name cannot be expressed with an attribute.'); + return; + } + if (count($derivableTags) > 0 || count($tags) > 0) { + $skipped[] = new SkippedEntry('services', $description, 'A non-autowired service is never auto-tagged, so its tags cannot be expressed with an attribute.'); + return; + } + + $arguments = [sprintf("name: '%s'", addslashes($name))]; + if ($factoryArgument !== null) { + $arguments[] = sprintf("factory: '%s'", addslashes($factoryArgument)); + } + $conversions[] = new ServiceConversion( + 'services', + $entryIndex, + $class, + $file, + sprintf('#[NonAutowiredService(%s)]', implode(', ', $arguments)), + $parameterAttributes, + $this->collectUseImports('NonAutowiredService', $parameterAttributes), + ); + return; + } + + $arguments = []; + if ($name !== null) { + $arguments[] = sprintf("name: '%s'", addslashes($name)); + } + if ($factoryArgument !== null) { + $arguments[] = sprintf("factory: '%s'", addslashes($factoryArgument)); + } + if ($autowired !== true) { + $asValue = $this->renderAutowiredAs($autowired); + if ($asValue === null) { + $skipped[] = new SkippedEntry('services', $description, 'The autowired value cannot be expressed with an attribute.'); + return; + } + $arguments[] = sprintf('as: %s', $asValue); + } + if (!$autoTag) { + $arguments[] = 'autoTag: false'; + } + + $conversions[] = new ServiceConversion( + 'services', + $entryIndex, + $class, + $file, + count($arguments) === 0 + ? '#[AutowiredService]' + : sprintf('#[AutowiredService(%s)]', implode(', ', $arguments)), + $parameterAttributes, + $this->collectUseImports('AutowiredService', $parameterAttributes), + ); + } + + /** + * Attribute code per constructor parameter for the entry's `arguments`, + * null (with a skip recorded) when they cannot be expressed. + * + * @param mixed $arguments + * @param ReflectionClass $reflection + * @param list $skipped + * @return array|null + */ + private function buildParameterAttributes($arguments, ReflectionClass $reflection, string $description, array &$skipped): ?array + { + if (!is_array($arguments)) { + $skipped[] = new SkippedEntry('services', $description, 'The arguments cannot be determined statically.'); + return null; + } + if (count($arguments) === 0) { + return []; + } + + $constructor = $reflection->getConstructor(); + $parameters = $constructor === null ? [] : $constructor->getParameters(); + $parameterNames = array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $parameters); + + $parameterAttributes = []; + foreach ($arguments as $argumentKey => $argumentValue) { + if (!is_string($argumentValue) || (preg_match('#^@[\w\\\\]+$#D', $argumentValue) !== 1 && preg_match('#^%[\w.-]+%$#D', $argumentValue) !== 1)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The argument %s is not a %%parameter%% or @service reference.', is_int($argumentKey) ? sprintf('#%d', $argumentKey) : $argumentKey)); + return null; + } + + if (is_int($argumentKey)) { + if (!array_key_exists($argumentKey, $parameterNames)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The constructor has no parameter #%d.', $argumentKey)); + return null; + } + $parameterName = $parameterNames[$argumentKey]; + } else { + if (!in_array($argumentKey, $parameterNames, true)) { + $skipped[] = new SkippedEntry('services', $description, sprintf('The constructor has no parameter $%s.', $argumentKey)); + return null; + } + $parameterName = $argumentKey; + } + + if ($argumentValue === '%' . $parameterName . '%') { + $parameterAttributes[$parameterName] = '#[AutowiredParameter]'; + } else { + $parameterAttributes[$parameterName] = sprintf("#[AutowiredParameter(ref: '%s')]", addslashes($argumentValue)); + } + } + + return $parameterAttributes; + } + + /** + * @param mixed $autowired + */ + private function renderAutowiredAs($autowired): ?string + { + if (is_string($autowired)) { + return '\\' . $autowired . '::class'; + } + + if (!is_array($autowired)) { + return null; + } + + $rendered = []; + foreach ($autowired as $autowiredClass) { + if (!is_string($autowiredClass)) { + return null; + } + + $rendered[] = '\\' . $autowiredClass . '::class'; + } + + return '[' . implode(', ', $rendered) . ']'; + } + + /** + * File of the class when the class can carry attributes editable by this command, + * null (with a skip recorded) otherwise. + * + * @param 'services'|'rules' $section + * @param list $skipped + */ + private function getConvertibleClassFile(string $class, string $section, array &$skipped, ?string $description = null): ?string + { + $description ??= $class; + + if (!class_exists($class) && !interface_exists($class)) { + $skipped[] = new SkippedEntry($section, $description, 'The class cannot be autoloaded.'); + return null; + } + + $reflection = new ReflectionClass($class); + $file = $reflection->getFileName(); + if ($file === false) { + $skipped[] = new SkippedEntry($section, $description, 'The class has no source file.'); + return null; + } + + $file = $this->fileHelper->normalizePath($file, '/'); + $normalizedRoot = rtrim($this->fileHelper->normalizePath($this->projectRoot, '/'), '/'); + if (!str_starts_with($file, $normalizedRoot . '/') || str_contains($file, '/vendor/')) { + $skipped[] = new SkippedEntry($section, $description, 'The class is not part of this project.'); + return null; + } + + foreach ($reflection->getAttributes() as $attribute) { + if (!str_starts_with($attribute->getName(), 'PHPStan\\DependencyInjection\\')) { + continue; + } + + $skipped[] = new SkippedEntry($section, $description, sprintf('The class already carries the %s attribute.', $attribute->getName())); + return null; + } + + if ($this->findCoveringAutoloadDirectory($file) === null) { + $skipped[] = new SkippedEntry($section, $description, 'The class file is not covered by a psr-4 or classmap autoload rule of composer.json.'); + return null; + } + + return $file; + } + + /** + * @param array $parameterAttributes + * @return list + */ + private function collectUseImports(string $attributeShortName, array $parameterAttributes): array + { + $imports = ['PHPStan\DependencyInjection\\' . $attributeShortName]; + if (count($parameterAttributes) > 0) { + $imports[] = 'PHPStan\DependencyInjection\AutowiredParameter'; + } + + return $imports; + } + + /** + * @param list $conversions + * @return list + */ + private function collectDirectoriesToDeclare(string $neonFile, array $conversions): array + { + $relativePathHelper = new ParentDirectoryRelativePathHelper(dirname($this->fileHelper->normalizePath($neonFile, '/'))); + + $directories = []; + foreach ($conversions as $conversion) { + $directory = $this->findCoveringAutoloadDirectory($conversion->phpFile); + if ($directory === null) { + continue; + } + + $directories[$directory] = true; + } + + $relative = array_map( + static fn (string $directory): string => strtr($relativePathHelper->getRelativePath($directory), '\\', '/'), + array_keys($directories), + ); + sort($relative); + + return $relative; + } + + private function findCoveringAutoloadDirectory(string $file): ?string + { + $rules = $this->getRootAutoloadRules(); + if ($rules === null) { + return null; + } + + foreach ($rules->psr4 as $baseDirectories) { + foreach ($baseDirectories as $baseDirectory) { + if (str_starts_with($file, $baseDirectory . '/')) { + return $baseDirectory; + } + } + } + + foreach ($rules->classmapPaths as $classmapPath) { + if ($file === $classmapPath || str_starts_with($file, $classmapPath . '/')) { + return $classmapPath; + } + } + + return null; + } + + private function getRootAutoloadRules(): ?AutoloadRules + { + if ($this->rootAutoloadRules !== null) { + return $this->rootAutoloadRules; + } + + $project = (new ComposerProjectFactory($this->fileHelper))->create($this->projectRoot); + if ($project === null) { + return null; + } + + return $this->rootAutoloadRules = $project->rootAutoload->union($project->rootAutoloadDev); + } + +} diff --git a/src/Command/Neon2Attributes/Neon2AttributesException.php b/src/Command/Neon2Attributes/Neon2AttributesException.php new file mode 100644 index 00000000000..ef69fe2a919 --- /dev/null +++ b/src/Command/Neon2Attributes/Neon2AttributesException.php @@ -0,0 +1,10 @@ + $conversions + * @param list $skipped + * @param list $directoriesToDeclare paths for the attributeServicesDirectories section, + * relative to the NEON file + */ + public function __construct( + public array $conversions, + public array $skipped, + public array $directoriesToDeclare, + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/NeonEditor.php b/src/Command/Neon2Attributes/NeonEditor.php new file mode 100644 index 00000000000..c3c82f19dcd --- /dev/null +++ b/src/Command/Neon2Attributes/NeonEditor.php @@ -0,0 +1,207 @@ + $entryIndexes + * @throws Neon2AttributesException + */ + public function removeEntries(string $content, string $section, array $entryIndexes, int $expectedEntryCount): string + { + if (count($entryIndexes) === 0) { + return $content; + } + + $lines = explode("\n", $content); + [$headerLine, $sectionEnd] = $this->findSection($lines, $section); + + $entryStarts = []; + $entryIndent = null; + for ($i = $headerLine + 1; $i < $sectionEnd; $i++) { + $line = $lines[$i]; + if (preg_match('/^(\s+)(\S)/', $line, $matches) !== 1) { + continue; + } + if ($matches[2] === '#') { + continue; + } + + $entryIndent ??= $matches[1]; + if ($matches[1] !== $entryIndent) { + continue; + } + + $entryStarts[] = $i; + } + + if (count($entryStarts) !== $expectedEntryCount) { + throw new Neon2AttributesException(sprintf( + 'Cannot map the `%s` section onto the file - found %d entries in the text but the decoded section has %d. The file layout is too unusual for automatic editing.', + $section, + count($entryStarts), + $expectedEntryCount, + )); + } + + $ranges = []; + foreach ($entryIndexes as $entryIndex) { + if (!isset($entryStarts[$entryIndex])) { + throw new Neon2AttributesException(sprintf('Entry #%d not found in the `%s` section.', $entryIndex, $section)); + } + + $start = $entryStarts[$entryIndex]; + $end = $entryStarts[$entryIndex + 1] ?? $sectionEnd; + $ranges[] = [$start, $end]; + } + + if (count($entryIndexes) === $expectedEntryCount) { + // the whole section goes away, header included + $ranges = [[$headerLine, $sectionEnd]]; + } + + usort($ranges, static fn (array $a, array $b): int => $b[0] <=> $a[0]); + foreach ($ranges as [$start, $end]) { + array_splice($lines, $start, $end - $start); + } + + return implode("\n", $lines); + } + + /** + * @param list $directories relative to the NEON file + * @throws Neon2AttributesException + */ + public function addDirectoriesSection(string $content, array $directories): string + { + if (count($directories) === 0) { + return $content; + } + + $lines = explode("\n", $content); + $indent = $this->detectIndent($lines); + + $existingHeader = null; + foreach ($lines as $i => $line) { + if (preg_match('/^attributeServicesDirectories:\s*(#.*)?$/', $line) !== 1) { + continue; + } + + $existingHeader = $i; + break; + } + + if ($existingHeader !== null) { + [$headerLine, $sectionEnd] = $this->findSection($lines, 'attributeServicesDirectories'); + $existingEntries = []; + for ($i = $headerLine + 1; $i < $sectionEnd; $i++) { + if (preg_match('/^\s+-\s*(.+?)\s*$/', $lines[$i], $matches) !== 1) { + continue; + } + + $existingEntries[] = $matches[1]; + } + + $newLines = []; + foreach ($directories as $directory) { + if (in_array($directory, $existingEntries, true)) { + continue; + } + + $newLines[] = $indent . '- ' . $directory; + } + + // insert right after the last existing entry (before any trailing blank lines) + $insertAt = $headerLine + 1; + for ($i = $headerLine + 1; $i < $sectionEnd; $i++) { + if ($lines[$i] === '') { + continue; + } + + $insertAt = $i + 1; + } + array_splice($lines, $insertAt, 0, $newLines); + + return implode("\n", $lines); + } + + $newLines = ['attributeServicesDirectories:']; + foreach ($directories as $directory) { + $newLines[] = $indent . '- ' . $directory; + } + $newLines[] = ''; + + array_splice($lines, 0, 0, $newLines); + + return implode("\n", $lines); + } + + /** + * @param list $lines + * @return array{int, int} header line index, exclusive section end index + * @throws Neon2AttributesException + */ + private function findSection(array $lines, string $section): array + { + $headerLine = null; + foreach ($lines as $i => $line) { + if (preg_match(sprintf('/^%s:\s*(#.*)?$/', preg_quote($section, '/')), $line) !== 1) { + continue; + } + + $headerLine = $i; + break; + } + + if ($headerLine === null) { + throw new Neon2AttributesException(sprintf('Cannot find the `%s` section in the file.', $section)); + } + + $sectionEnd = count($lines); + for ($i = $headerLine + 1; $i < count($lines); $i++) { + if (preg_match('/^[^\s#]/', $lines[$i]) !== 1) { + continue; + } + + $sectionEnd = $i; + break; + } + + return [$headerLine, $sectionEnd]; + } + + /** + * @param list $lines + */ + private function detectIndent(array $lines): string + { + foreach ($lines as $line) { + if (preg_match('/^(\t+| +)\S/', $line, $matches) !== 1) { + continue; + } + + return $matches[1]; + } + + return "\t"; + } + +} diff --git a/src/Command/Neon2Attributes/PhpAttributeInserter.php b/src/Command/Neon2Attributes/PhpAttributeInserter.php new file mode 100644 index 00000000000..2d75437b6aa --- /dev/null +++ b/src/Command/Neon2Attributes/PhpAttributeInserter.php @@ -0,0 +1,246 @@ + $conversions conversions whose classes are declared in this content + * @throws Neon2AttributesException + */ + public function insert(string $content, array $conversions): string + { + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $stmts = $parser->parse($content); + if ($stmts === null) { + throw new Neon2AttributesException('Cannot parse the PHP file.'); + } + + $traverser = new NodeTraverser(new NameResolver(options: ['replaceNodes' => false])); + $stmts = $traverser->traverse($stmts); + $nodeFinder = new NodeFinder(); + + $lines = explode("\n", $content); + + $existingImports = []; + $existingAliases = []; + $lastUseLine = null; + $firstUseLine = null; + foreach ($nodeFinder->findInstanceOf($stmts, Use_::class) as $use) { + if ($use->type !== Use_::TYPE_NORMAL) { + continue; + } + foreach ($use->uses as $useUse) { + $existingImports[strtolower($useUse->name->toString())] = true; + $existingAliases[strtolower($useUse->getAlias()->toString())] = $useUse->name->toString(); + } + $firstUseLine ??= $use->getStartLine(); + $lastUseLine = $use->getEndLine(); + } + + /** @var list}> $insertions line number (1-based, insert before) => lines */ + $insertions = []; + $importsToAdd = []; + + foreach ($conversions as $conversion) { + $classNode = null; + foreach ($nodeFinder->findInstanceOf($stmts, ClassLike::class) as $candidate) { + if ($candidate->namespacedName === null || strcasecmp($candidate->namespacedName->toString(), $conversion->className) !== 0) { + continue; + } + + $classNode = $candidate; + break; + } + + if ($classNode === null) { + throw new Neon2AttributesException(sprintf('Class %s is not declared in %s.', $conversion->className, $conversion->phpFile)); + } + + $attributeCode = $conversion->attributeCode; + $parameterAttributes = $conversion->parameterAttributes; + foreach ($conversion->useImports as $import) { + $shortName = self::getShortName($import); + $lowerImport = strtolower($import); + $lowerShortName = strtolower($shortName); + if (isset($existingImports[$lowerImport])) { + continue; + } + if (isset($existingAliases[$lowerShortName]) && strcasecmp($existingAliases[$lowerShortName], $import) !== 0) { + // another class already claims the short name - fall back to the fully qualified form + $attributeCode = str_replace('#[' . $shortName, '#[\\' . $import, $attributeCode); + foreach ($parameterAttributes as $parameterName => $parameterAttributeCode) { + $parameterAttributes[$parameterName] = str_replace('#[' . $shortName, '#[\\' . $import, $parameterAttributeCode); + } + continue; + } + + $importsToAdd[$import] = true; + $existingAliases[$lowerShortName] = $import; + $existingImports[$lowerImport] = true; + } + + $classLine = $classNode->getStartLine(); + $indent = self::getIndent($lines[$classLine - 1] ?? ''); + $insertions[] = [$classLine, [$indent . $attributeCode]]; + + if (count($parameterAttributes) === 0) { + continue; + } + + $constructor = null; + foreach ($classNode->getMethods() as $method) { + if (strcasecmp($method->name->toString(), '__construct') !== 0) { + continue; + } + + $constructor = $method; + break; + } + if ($constructor === null) { + throw new Neon2AttributesException(sprintf('Class %s has no constructor to carry #[AutowiredParameter].', $conversion->className)); + } + + foreach ($parameterAttributes as $parameterName => $parameterAttributeCode) { + $parameterNode = null; + foreach ($constructor->getParams() as $param) { + if (!$param->var instanceof Variable || $param->var->name !== $parameterName) { + continue; + } + + $parameterNode = $param; + break; + } + if ($parameterNode === null) { + throw new Neon2AttributesException(sprintf('Constructor of %s has no parameter $%s.', $conversion->className, $parameterName)); + } + + $parameterLine = $parameterNode->getStartLine(); + if (!self::parameterStartsItsLine($constructor, $parameterLine, $parameterNode)) { + throw new Neon2AttributesException(sprintf('Parameter $%s of %s does not start its own line, cannot insert #[AutowiredParameter] deterministically.', $parameterName, $conversion->className)); + } + + $parameterIndent = self::getIndent($lines[$parameterLine - 1] ?? ''); + $insertions[] = [$parameterLine, [$parameterIndent . $parameterAttributeCode]]; + } + } + + if (count($importsToAdd) > 0) { + $insertions[] = $this->buildImportInsertion($lines, $stmts, $nodeFinder, $importsToAdd, $firstUseLine, $lastUseLine); + } + + usort($insertions, static fn (array $a, array $b): int => $b[0] <=> $a[0]); + foreach ($insertions as [$line, $newLines]) { + array_splice($lines, $line - 1, 0, $newLines); + } + + return implode("\n", $lines); + } + + /** + * @param list $lines + * @param Node[] $stmts + * @param array $importsToAdd + * @return array{int, list} + * @throws Neon2AttributesException + */ + private function buildImportInsertion(array $lines, array $stmts, NodeFinder $nodeFinder, array $importsToAdd, ?int $firstUseLine, ?int $lastUseLine): array + { + $newImports = array_keys($importsToAdd); + usort($newImports, static fn (string $a, string $b): int => strcasecmp($a, $b)); + $newLines = []; + foreach ($newImports as $import) { + $newLines[] = sprintf('use %s;', $import); + } + + if ($firstUseLine !== null && $lastUseLine !== null) { + // insert after the last existing import; exact alphabetical interleaving would + // require reordering foreign lines, appending keeps the edit minimal + return [$lastUseLine + 1, $newLines]; + } + + $namespaceNodes = $nodeFinder->findInstanceOf($stmts, Namespace_::class); + if (count($namespaceNodes) > 0 && $namespaceNodes[0]->name !== null) { + $namespaceLine = $namespaceNodes[0]->name->getEndLine(); + $insertAt = $namespaceLine + 1; + while (isset($lines[$insertAt - 1]) && $lines[$insertAt - 1] === '') { + $insertAt++; + } + + $newLines[] = ''; + return [$insertAt, $newLines]; + } + + throw new Neon2AttributesException('Cannot find a place for the use imports - the file has no namespace declaration.'); + } + + private static function parameterStartsItsLine(ClassMethod $constructor, int $parameterLine, Param $parameterNode): bool + { + if ($constructor->getStartLine() === $parameterLine) { + return false; + } + + foreach ($constructor->getParams() as $param) { + if ($param === $parameterNode) { + continue; + } + if ($param->getStartLine() === $parameterLine || $param->getEndLine() === $parameterLine) { + return false; + } + } + + return true; + } + + private static function getIndent(string $line): string + { + $matches = []; + if (preg_match('/^(\s*)/', $line, $matches) === 1) { + return $matches[1]; + } + + return ''; + } + + private static function getShortName(string $className): string + { + $pos = strrpos($className, '\\'); + if ($pos === false) { + return $className; + } + + return substr($className, $pos + 1); + } + +} diff --git a/src/Command/Neon2Attributes/ServiceConversion.php b/src/Command/Neon2Attributes/ServiceConversion.php new file mode 100644 index 00000000000..e70e72b583b --- /dev/null +++ b/src/Command/Neon2Attributes/ServiceConversion.php @@ -0,0 +1,29 @@ + $parameterAttributes constructor parameter name => attribute code + * @param list $useImports fully qualified names to import + */ + public function __construct( + public string $section, + public int $entryIndex, + public string $className, + public string $phpFile, + public string $attributeCode, + public array $parameterAttributes, + public array $useImports, + ) + { + } + +} diff --git a/src/Command/Neon2Attributes/SkippedEntry.php b/src/Command/Neon2Attributes/SkippedEntry.php new file mode 100644 index 00000000000..1bcc0de8bfe --- /dev/null +++ b/src/Command/Neon2Attributes/SkippedEntry.php @@ -0,0 +1,22 @@ +setName(self::NAME) + ->setDescription('Converts services and rules registered in a NEON file into PHPStan DI attributes on their classes') + ->setDefinition([ + new InputArgument('neon-file', InputArgument::REQUIRED, 'Path to the NEON file to convert'), + new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Only print what would be converted'), + new InputOption('print-service-fingerprint', null, InputOption::VALUE_NONE, '(internal) Print the tagged-services fingerprint of the configuration'), + ]); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (PHP_VERSION_ID < 80000) { + $output->writeln('The neon2attributes command requires PHP 8.0 or later.'); + return 1; + } + + $currentWorkingDirectory = getcwd(); + if ($currentWorkingDirectory === false) { + throw new ShouldNotHappenException(); + } + $fileHelper = new FileHelper($currentWorkingDirectory); + + $neonFileArgument = $input->getArgument('neon-file'); + if (!is_string($neonFileArgument)) { + throw new ShouldNotHappenException(); + } + $neonFile = $fileHelper->normalizePath($fileHelper->absolutizePath($neonFileArgument), '/'); + if (!is_file($neonFile)) { + $output->writeln(sprintf('File %s does not exist.', $neonFile)); + return 1; + } + + if ((bool) $input->getOption('print-service-fingerprint')) { + return $this->printFingerprint($neonFile, $currentWorkingDirectory, $output); + } + + $analyzer = new Neon2AttributesAnalyzer($fileHelper, $currentWorkingDirectory); + try { + $plan = $analyzer->analyze($neonFile); + } catch (Neon2AttributesException $e) { + $output->writeln(sprintf('%s', $e->getMessage())); + return 1; + } + + [$plan, $newContents] = $this->buildPhpFileEdits($plan); + + foreach ($plan->conversions as $conversion) { + $output->writeln(sprintf('Converting %s → %s', $conversion->className, $conversion->attributeCode)); + } + foreach ($plan->skipped as $skipped) { + $output->writeln(sprintf('Keeping %s in `%s`: %s', $skipped->description, $skipped->section, $skipped->reason)); + } + + if (count($plan->conversions) === 0) { + $output->writeln('Nothing to convert.'); + return 0; + } + + if ((bool) $input->getOption('dry-run')) { + $output->writeln(sprintf('Would convert %d entries (dry run, nothing written).', count($plan->conversions))); + return 0; + } + + $originalFingerprint = $this->computeFingerprintInSubprocess($neonFile, $output); + if ($originalFingerprint === null) { + $output->writeln('The original configuration does not compile on its own - the conversion cannot be verified automatically.'); + } + + try { + $newContents[$neonFile] = $this->computeNeonContent($neonFile, $plan); + } catch (Neon2AttributesException $e) { + $output->writeln(sprintf('%s', $e->getMessage())); + return 1; + } + + $backups = []; + foreach ($newContents as $file => $newContent) { + $backups[$file] = FileReader::read($file); + } + foreach ($newContents as $file => $newContent) { + FileWriter::write($file, $newContent); + } + + if ($originalFingerprint !== null) { + $convertedFingerprint = $this->computeFingerprintInSubprocess($neonFile, $output); + if ($convertedFingerprint === null || $convertedFingerprint !== $originalFingerprint) { + foreach ($backups as $file => $originalContent) { + FileWriter::write($file, $originalContent); + } + + if ($convertedFingerprint === null) { + $output->writeln('The converted configuration does not compile - all changes were rolled back.'); + } else { + $output->writeln('The converted configuration compiles into different tagged services than the original - all changes were rolled back.'); + $this->printFingerprintDiff($originalFingerprint, $convertedFingerprint, $output); + } + + return 1; + } + + $output->writeln('Verified: the converted configuration compiles into the same tagged services as the original.'); + } + + $output->writeln(sprintf('Converted %d entries; %d entries stay in the NEON file.', count($plan->conversions), count($plan->skipped))); + + return 0; + } + + /** + * Applies the attribute insertions file by file. A file the inserter cannot edit + * deterministically (an unusual layout) demotes its conversions back to kept entries + * instead of aborting the whole run. + * + * @return array{Neon2AttributesPlan, array} adjusted plan, php file => new content + */ + private function buildPhpFileEdits(Neon2AttributesPlan $plan): array + { + $conversionsByFile = []; + foreach ($plan->conversions as $conversion) { + $conversionsByFile[$conversion->phpFile][] = $conversion; + } + + $inserter = new PhpAttributeInserter(); + $survivingConversions = []; + $skipped = $plan->skipped; + $newContents = []; + foreach ($conversionsByFile as $file => $conversions) { + try { + $newContents[$file] = $inserter->insert(FileReader::read($file), $conversions); + } catch (Neon2AttributesException $e) { + foreach ($conversions as $conversion) { + $skipped[] = new SkippedEntry($conversion->section, $conversion->className, $e->getMessage()); + } + continue; + } + + foreach ($conversions as $conversion) { + $survivingConversions[] = $conversion; + } + } + + return [new Neon2AttributesPlan($survivingConversions, $skipped, $plan->directoriesToDeclare), $newContents]; + } + + /** + * @throws Neon2AttributesException + */ + private function computeNeonContent(string $neonFile, Neon2AttributesPlan $plan): string + { + $sectionIndexes = ['rules' => [], 'services' => []]; + $sectionCounts = ['rules' => 0, 'services' => 0]; + foreach ($plan->conversions as $conversion) { + $sectionIndexes[$conversion->section][] = $conversion->entryIndex; + } + $decoded = Neon::decode(FileReader::read($neonFile)); + if (is_array($decoded)) { + foreach (['rules', 'services'] as $section) { + $sectionValue = $decoded[$section] ?? []; + $sectionCounts[$section] = is_array($sectionValue) ? count($sectionValue) : 0; + } + } + + $editor = new NeonEditor(); + $neonContent = FileReader::read($neonFile); + foreach (['rules', 'services'] as $section) { + $neonContent = $editor->removeEntries($neonContent, $section, $sectionIndexes[$section], $sectionCounts[$section]); + } + + return $editor->addDirectoriesSection($neonContent, $plan->directoriesToDeclare); + } + + private function printFingerprint(string $neonFile, string $currentWorkingDirectory, OutputInterface $output): int + { + $tmpDir = sys_get_temp_dir() . '/phpstan-neon2attributes-' . md5(uniqid(more_entropy: true)); + @mkdir($tmpDir, 0777, true); + + // with a rule level in play (level 0 keeps the diff minimal), so that classes converted + // to #[RegisteredRule]/#[RegisteredCollector] register just like their rules:/tagged + // originals; without it autowiredAttributeServices.level stays null and none would + $container = (new ContainerFactory($currentWorkingDirectory))->create($tmpDir, [__DIR__ . '/../../conf/config.level0.neon', $neonFile], [], [$currentWorkingDirectory]); + $fingerprint = $this->buildFingerprint($container); + + $output->writeln(self::FINGERPRINT_DELIMITER); + $output->writeln(Json::encode($fingerprint)); + $output->writeln(self::FINGERPRINT_DELIMITER); + + return 0; + } + + /** + * Tag => sorted service class names of the compiled container. What Ondrej and Caleb + * compared by hand in the issue - same classes, same tags, same multiplicities. + * + * @return array> + */ + private function buildFingerprint(Container $container): array + { + $netteContainer = $container->getByType(NetteDIContainer::class); + + $tagsProperty = new ReflectionProperty(NetteDIContainer::class, 'tags'); + /** @var array> $tags */ + $tags = $tagsProperty->getValue($netteContainer); + + $fingerprint = []; + foreach ($tags as $tag => $services) { + $types = []; + foreach (array_keys($services) as $serviceName) { + $types[] = $netteContainer->getServiceType((string) $serviceName); + } + sort($types); + $fingerprint[$tag] = $types; + } + + ksort($fingerprint); + + return $fingerprint; + } + + /** + * @return array>|null + */ + private function computeFingerprintInSubprocess(string $neonFile, OutputInterface $output): ?array + { + $phpstanBinary = $_SERVER['argv'][0] ?? null; + if (!is_string($phpstanBinary)) { + return null; + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open( + [PHP_BINARY, $phpstanBinary, self::NAME, '--print-service-fingerprint', $neonFile], + $descriptorSpec, + $pipes, + ); + if (!is_resource($process)) { + return null; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + if ($exitCode !== 0 || $stdout === false) { + if ($output->isVerbose() && $stderr !== false && $stderr !== '') { + $output->writeln($stderr); + } + + return null; + } + + $matches = []; + if (preg_match('/' . self::FINGERPRINT_DELIMITER . '\n(.*)\n' . self::FINGERPRINT_DELIMITER . '/s', $stdout, $matches) !== 1) { + return null; + } + + try { + $decoded = Json::decode($matches[1], Json::FORCE_ARRAY); + } catch (JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array> $original + * @param array> $converted + */ + private function printFingerprintDiff(array $original, array $converted, OutputInterface $output): void + { + foreach ($original as $tag => $types) { + $convertedTypes = $converted[$tag] ?? []; + if ($types === $convertedTypes) { + continue; + } + + $output->writeln(sprintf('Tag %s: %d services originally, %d after conversion.', $tag, count($types), count($convertedTypes))); + } + foreach ($converted as $tag => $types) { + if (isset($original[$tag])) { + continue; + } + + $output->writeln(sprintf('Tag %s: 0 services originally, %d after conversion.', $tag, count($types))); + } + } + +} diff --git a/src/Dependency/PackageDependencyResolver.php b/src/Dependency/PackageDependencyResolver.php index ae958d8e169..078f77be905 100644 --- a/src/Dependency/PackageDependencyResolver.php +++ b/src/Dependency/PackageDependencyResolver.php @@ -12,6 +12,7 @@ use function is_array; use function is_file; use function is_string; +use function rtrim; use function str_starts_with; use function strlen; use function uksort; @@ -57,6 +58,21 @@ public function resolvePackage(string $file): ?string return $this->resolvedPackages[$file] = $this->doResolvePackage($file); } + /** + * Unlike resolvePackage(), also matches a directory that IS a package's install path. + */ + public function resolveDirectoryPackage(string $directory): ?string + { + $normalizedDirectory = rtrim($this->fileHelper->normalizePath($directory, '/'), '/'); + foreach ($this->getInstallPathToPackage() as $installPath => $package) { + if ($normalizedDirectory === $installPath || str_starts_with($normalizedDirectory, $installPath . '/')) { + return $package; + } + } + + return null; + } + private function doResolvePackage(string $file): ?string { // Normalize with a forward slash regardless of platform: normalizePath() defaults to diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php b/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php new file mode 100644 index 00000000000..821a4a3733b --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolver.php @@ -0,0 +1,382 @@ +|null */ + private ?array $composerProjects = null; + + /** + * @param string[] $composerAutoloaderProjectPaths + */ + public function __construct( + private FileHelper $fileHelper, + private array $composerAutoloaderProjectPaths, + private int $runtimePhpVersionId = PHP_VERSION_ID, + ) + { + $this->composerProjectFactory = new ComposerProjectFactory($fileHelper); + } + + /** + * @param mixed $rawSectionValue + * @throws InvalidAttributeServicesDirectoriesException + */ + public function resolve($rawSectionValue): ResolvedAttributeServicesDirectories + { + if ($rawSectionValue === null || $rawSectionValue === []) { + return ResolvedAttributeServicesDirectories::createEmpty(); + } + + if (!is_array($rawSectionValue)) { + throw new InvalidAttributeServicesDirectoriesException([ + 'The attributeServicesDirectories section must contain a list of directory paths.', + ]); + } + + $errors = []; + $directories = []; + foreach ($rawSectionValue as $entry) { + if (!is_string($entry)) { + $errors[] = 'The attributeServicesDirectories section must contain a list of directory paths.'; + continue; + } + if (str_contains($entry, '%')) { + $errors[] = sprintf('Entry %s in the attributeServicesDirectories section must be a plain path - %% parameters are not supported.', $entry); + continue; + } + if (str_starts_with($entry, '*')) { + $errors[] = sprintf('Entry %s in the attributeServicesDirectories section must be a plain path - wildcards are not supported.', $entry); + continue; + } + + $directories[] = rtrim($this->fileHelper->normalizePath($this->fileHelper->absolutizePath($entry), '/'), '/'); + } + + $directories = $this->deduplicateNested($directories); + + if (count($directories) > 0 && $this->runtimePhpVersionId < 80000) { + $errors[] = sprintf( + 'The attributeServicesDirectories section requires PHP 8.0 or later, PHPStan is running on PHP %d.%d.%d.', + intdiv($this->runtimePhpVersionId, 10000), + intdiv($this->runtimePhpVersionId, 100) % 100, + $this->runtimePhpVersionId % 100, + ); + throw new InvalidAttributeServicesDirectoriesException($errors); + } + + $resolved = []; + foreach ($directories as $directory) { + if (!is_dir($directory)) { + $errors[] = sprintf('Directory %s from the attributeServicesDirectories section does not exist.', $directory); + continue; + } + + $resolvedDirectory = $this->resolveDirectory($directory, $errors); + if ($resolvedDirectory === null) { + continue; + } + + $resolved[] = $resolvedDirectory; + } + + if (count($errors) > 0) { + throw new InvalidAttributeServicesDirectoriesException($errors); + } + + return new ResolvedAttributeServicesDirectories($resolved); + } + + /** + * @param list $errors + */ + 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; + } + + if ($package !== null && $package->cacheToken !== null) { + $cacheKeyComponent = [$directory => sprintf('package:%s:%s', $package->name, $package->cacheToken)]; + } else { + $cacheKeyComponent = $this->hashDirectory($directory, $errors); + } + + 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 + { + 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; + } + + if ($containing !== null && strlen($containing->rootPath) >= strlen($project->rootPath)) { + continue; + } + + $containing = $project; + } + + if ($containing === null) { + return null; + } + + return [$containing, null]; + } + + /** + * @return list + */ + private function getComposerProjects(): array + { + if ($this->composerProjects !== null) { + return $this->composerProjects; + } + + $projects = []; + foreach ($this->composerAutoloaderProjectPaths as $projectPath) { + $project = $this->composerProjectFactory->create($projectPath); + if ($project === null) { + continue; + } + + $projects[] = $project; + } + + 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 all PHP files under the directory - the cache-key fallback when + * a package version cannot stand in for the directory contents. Mirrors how config + * files invalidate the container in Configurator::getAllConfigFilesHashes(). + * + * @param list $errors + * @return array + */ + private function hashDirectory(string $directory, array &$errors): array + { + $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[] = $this->fileHelper->normalizePath($fileInfo->getPathname(), '/'); + } + + sort($files); + + $hashes = []; + foreach ($files as $file) { + $hash = hash_file('sha256', $file); + if ($hash === false) { + $errors[] = (new CouldNotReadFileException($file))->getMessage(); + continue; + } + + $hashes[$file] = $hash; + } + + ksort($hashes); + + return $hashes; + } + + /** + * Unique directories with entries nested inside another configured directory dropped - + * the outer directory already covers them for both discovery and hashing. + * + * @param list $directories + * @return list + */ + private function deduplicateNested(array $directories): array + { + usort($directories, static fn (string $a, string $b): int => strlen($a) <=> strlen($b)); + + $result = []; + foreach ($directories as $directory) { + foreach ($result as $kept) { + if ($directory === $kept || str_starts_with($directory, $kept . '/')) { + continue 2; + } + } + + $result[] = $directory; + } + + sort($result); + + return $result; + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php new file mode 100644 index 00000000000..b4edbdc0579 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoverer.php @@ -0,0 +1,483 @@ +>> autoload_classmap.php path => file => class names */ + private array $reverseClassmaps = []; + + /** @var list */ + private array $errors = []; + + /** + * @throws InvalidAttributeServicesDirectoriesException + */ + public function discover(ResolvedAttributeServicesDirectories $resolvedDirectories): DiscoveredAttributeTargets + { + $this->errors = []; + $targetClasses = []; + $targetMethodParameters = []; + $seenClasses = []; + + foreach ($resolvedDirectories->directories as $directory) { + foreach ($this->findCandidateClasses($directory) as $className => $file) { + if (array_key_exists(strtolower($className), $seenClasses)) { + continue; + } + $seenClasses[strtolower($className)] = true; + + $this->collectClass($className, $file, $targetClasses, $targetMethodParameters); + } + } + + if (count($this->errors) > 0) { + throw new InvalidAttributeServicesDirectoriesException($this->errors); + } + + foreach ($targetClasses as $attributeClass => $targets) { + usort($targets, static fn (TargetClass $a, TargetClass $b): int => $a->name <=> $b->name); + $targetClasses[$attributeClass] = $targets; + } + foreach ($targetMethodParameters as $attributeClass => $targets) { + usort($targets, static fn (TargetMethodParameter $a, TargetMethodParameter $b): int => [$a->class, $a->name] <=> [$b->class, $b->name]); + $targetMethodParameters[$attributeClass] = $targets; + } + + 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 ($this->suggestsDiAttributes($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 (!$this->suggestsDiAttributes($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 + { + if (!$this->isPrefilterPositive($file)) { + return; + } + + if (!$this->classCanBeLoaded($className)) { + if ($this->suggestsDiAttributes($file)) { + $this->errors[] = sprintf( + 'Class %s expected in %s (through a directory from the attributeServicesDirectories section) cannot be autoloaded.', + $className, + $file, + ); + } + + return; + } + + /** @var class-string $className */ + $reflection = new ReflectionClass($className); + + foreach ($reflection->getAttributes() as $attribute) { + $attributeClass = $this->resolveKnownAttribute($attribute->getName(), self::PUBLIC_CLASS_ATTRIBUTES); + if ($attributeClass !== null) { + try { + $attributeInstance = $attribute->newInstance(); + } catch (Throwable $e) { + $this->errors[] = sprintf('Cannot instantiate attribute #[%s] on class %s: %s', $this->getShortName($attribute->getName()), $className, $e->getMessage()); + continue; + } + + $targetClasses[$attributeClass][] = new TargetClass($attributeInstance, $className); + continue; + } + + $this->checkDisallowedAttribute($attribute->getName(), $className); + } + + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return; + } + + foreach ($constructor->getParameters() as $parameter) { + foreach ($parameter->getAttributes() as $attribute) { + $attributeClass = $this->resolveKnownAttribute($attribute->getName(), self::PUBLIC_PARAMETER_ATTRIBUTES); + if ($attributeClass !== null) { + try { + $attributeInstance = $attribute->newInstance(); + } catch (Throwable $e) { + $this->errors[] = sprintf('Cannot instantiate attribute #[%s] on a constructor parameter of class %s: %s', $this->getShortName($attribute->getName()), $className, $e->getMessage()); + continue; + } + + $targetMethodParameters[$attributeClass][] = new TargetMethodParameter($attributeInstance, $className, $parameter->getName(), '__construct'); + continue; + } + + $this->checkDisallowedAttribute($attribute->getName(), $className); + } + } + } + + private function checkDisallowedAttribute(string $attributeName, string $className): void + { + if (!$this->isPhpStanAttribute($attributeName)) { + return; + } + + $lowerAttributeName = strtolower($attributeName); + if ($lowerAttributeName === strtolower(ContainerExtension::class)) { + $this->errors[] = sprintf( + 'Attribute #[ContainerExtension] on class %s 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.', + $className, + ); + 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.', + $className, + ); + return; + } + + $this->errors[] = sprintf( + 'Attribute #[%s] on class %s is only supported on classes shipped with PHPStan itself, not on classes discovered through the attributeServicesDirectories section.', + $this->getShortName($attributeName), + $className, + ); + } + + /** + * Canonical attribute class name when $attributeName is one of $allowedAttributes, null otherwise. + * + * @param list $allowedAttributes + * @return class-string|null + */ + private function resolveKnownAttribute(string $attributeName, array $allowedAttributes): ?string + { + foreach ($allowedAttributes as $allowedAttribute) { + if (strtolower($attributeName) === strtolower($allowedAttribute)) { + return $allowedAttribute; + } + } + + return null; + } + + private function isPhpStanAttribute(string $attributeName): bool + { + if (!class_exists($attributeName)) { + return false; + } + + $file = (new ReflectionClass($attributeName))->getFileName(); + if ($file === false) { + return false; + } + + $phpstanRoot = strtr(dirname(__DIR__, 3), '\\', '/'); + + return str_starts_with(strtr($file, '\\', '/'), $phpstanRoot . '/'); + } + + private function classCanBeLoaded(string $className): bool + { + try { + if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + return true; + } + + return function_exists('enum_exists') && enum_exists($className); + } catch (Throwable) { + return false; + } + } + + /** + * @return list normalized with forward slashes, sorted + */ + private function listPhpFiles(string $directory): array + { + $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); + + return $files; + } + + /** + * 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). + */ + private function derivePsr4ClassName(ResolvedAttributeServicesDirectory $directory, string $file): ?string + { + $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; + } + } + + if ($bestBaseDirectory === null || $bestPrefix === null) { + return null; + } + + $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; + } + } + + return $bestPrefix . strtr($relativePath, '/', '\\'); + } + + /** + * 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 + */ + private function findClassmapClasses(ResolvedAttributeServicesDirectory $directory, string $file): ?array + { + $covered = false; + foreach ($directory->classmapPaths as $classmapPath) { + if ($file === $classmapPath || str_starts_with($file, $classmapPath . '/')) { + $covered = true; + break; + } + } + + if (!$covered) { + return null; + } + + $reverseClassmap = $this->getReverseClassmap($directory->autoloadClassmapPath); + + return $reverseClassmap[$file] ?? []; + } + + /** + * @return array> file => class names + */ + private function getReverseClassmap(string $autoloadClassmapPath): 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; + } + } + } + + return $this->reverseClassmaps[$autoloadClassmapPath] = $reverse; + } + + /** + * 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. + */ + private function isPrefilterPositive(string $file): bool + { + $contents = FileReader::read($file); + + return str_contains($contents, '#[') && stripos($contents, 'phpstan') !== false; + } + + /** + * Stricter check used only to decide whether a file that yielded no autoloadable class + * deserves an error: does it mention any of the DI attributes by name? + */ + private function suggestsDiAttributes(string $file): bool + { + $contents = FileReader::read($file); + if (!str_contains($contents, '#[')) { + return false; + } + + foreach (self::ATTRIBUTE_SHORT_NAMES as $shortName) { + if (stripos($contents, $shortName) !== false) { + return true; + } + } + + return false; + } + + private function getShortName(string $className): string + { + $parts = explode('\\', $className); + + return $parts[count($parts) - 1]; + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php new file mode 100644 index 00000000000..1f28ed2f114 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesDiscoveryContext.php @@ -0,0 +1,47 @@ +directories) === 0) { + return self::$targets = DiscoveredAttributeTargets::createEmpty(); + } + + return self::$targets = (new AttributeServicesDiscoverer())->discover(self::$currentDirectories); + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php new file mode 100644 index 00000000000..0cbea0b0062 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeServicesRegistrar.php @@ -0,0 +1,262 @@ +findTargetClasses(AutowiredService::class) as $class) { + $reflection = new ReflectionClass($class->name); + $attribute = $class->attribute; + + $definition = $builder->addDefinition($attribute->name) + ->setType($class->name) + ->setAutowired($attribute->as); + + if ($attribute->factory !== null) { + [$ref, $method] = explode('::', $attribute->factory); + $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); + } + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + + if (!$attribute->autoTag) { + continue; + } + + foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { + if (!$reflection->implementsInterface($interface)) { + continue; + } + + $definition->addTag($tag); + } + } + + foreach ($targets->findTargetClasses(NonAutowiredService::class) as $class) { + $attribute = $class->attribute; + + $definition = $builder->addDefinition($attribute->name) + ->setType($class->name) + ->setAutowired(false); + + if ($attribute->factory !== null) { + [$ref, $method] = explode('::', $attribute->factory); + $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); + } + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + } + + foreach ($targets->findTargetClasses(GenerateFactory::class) as $class) { + $attribute = $class->attribute; + $definition = $builder->addFactoryDefinition(null) + ->setImplement($attribute->interface); + + if ($attribute->resultType !== null) { + $definition->getResultDefinition()->setType($attribute->resultType); + } + + $resultDefinition = $definition->getResultDefinition(); + self::processConstructorParameters($builder, $class->name, $resultDefinition, $constructorParameters); + } + + if ($level === null) { + return; + } + + foreach ($targets->findTargetClasses(RegisteredRule::class) as $class) { + $attribute = $class->attribute; + if ($attribute->level > $level) { + continue; + } + + $definition = $builder->addDefinition(null) + ->setFactory($class->name) + ->setAutowired($class->name) + ->addTag(LazyRegistry::RULE_TAG); + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + } + + foreach ($targets->findTargetClasses(RegisteredCollector::class) as $class) { + $attribute = $class->attribute; + if ($attribute->level > $level) { + continue; + } + + $definition = $builder->addDefinition(null) + ->setFactory($class->name) + ->setAutowired($class->name) + ->addTag(RegistryFactory::COLLECTOR_TAG); + + self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + } + } + + /** + * @return array>> + */ + public static function collectConstructorParameters(AttributeTargetsProvider $targets): array + { + $constructorParameters = []; + foreach ($targets->findTargetMethodParameters(AutowiredParameter::class) as $parameter) { + if (strcasecmp($parameter->method, '__construct') !== 0) { + continue; + } + $lowerClass = strtolower($parameter->class); + $constructorParameters[$lowerClass] ??= []; + $constructorParameters[$lowerClass][] = $parameter; + } + + return $constructorParameters; + } + + /** + * @param class-string $className + * @param array>> $constructorParameters + */ + 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); + } + $definition->setArgument($autowiredParameter->name, $argument); + } + } + + /** + * Turns a `%foo%` reference into a deferred `$this->getParameter('foo')` lookup instead of reading + * ContainerBuilder::$parameters right now. Extensions registered after this one still rewrite the + * parameters during loadConfiguration() - ValidateExcludePathsExtension unwraps OptionalPath objects + * in `excludePaths` - and a value snapshotted here would keep the pre-rewrite contents. + * + * `%foo.bar%` becomes `$this->getParameter('foo')['bar']` and `%foo%/suffix` becomes a concatenation, + * mirroring how Nette itself compiles references to dynamic parameters. + * + * @return PhpLiteral|string + * @throws ShouldNotHappenException when the reference points at a parameter that does not exist + */ + private static function createDeferredParameter(ContainerBuilder $builder, string $ref) + { + $parts = preg_split('#%([\w.-]*)%#', $ref, flags: PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) { + throw new ShouldNotHappenException(); + } + + $dumper = new Dumper(); + $lookups = []; + $pieces = []; + $withoutReferences = ''; + foreach ($parts as $i => $part) { + if ($i % 2 === 0) { + if ($part !== '') { + $pieces[] = $dumper->dump($part); + $withoutReferences .= $part; + } + continue; + } + + if ($part === '') { + // '%%' is an escaped percent sign + $pieces[] = $dumper->dump('%'); + $withoutReferences .= '%'; + continue; + } + + $keys = explode('.', $part); + self::checkParameterExists($builder, $part, $keys); + + $code = $dumper->format('$this->getParameter(?)', $keys[0]); + foreach (array_slice($keys, 1) as $key) { + $code .= sprintf('[%s]', $dumper->dump($key)); + } + + $lookups[] = $code; + $pieces[] = sprintf('(%s)', $code); + } + + if (count($lookups) === 0) { + return $withoutReferences; + } + + if (count($pieces) === 1) { + // the reference is the whole value, no string coercion + return ContainerBuilder::literal($lookups[0]); + } + + return ContainerBuilder::literal(implode(' . ', $pieces)); + } + + /** + * The values are resolved at runtime but the keys never change after loadConfiguration(), + * so a typo in a reference is still caught while compiling the container. + * + * @param non-empty-list $keys + * @throws ShouldNotHappenException + */ + private static function checkParameterExists(ContainerBuilder $builder, string $ref, array $keys): void + { + $value = $builder->parameters; + foreach ($keys as $key) { + if (!is_array($value)) { + // a dynamic parameter - cannot be traversed while compiling + return; + } + if (!array_key_exists($key, $value)) { + throw new ShouldNotHappenException(sprintf("Missing parameter '%s'.", $ref)); + } + + $value = $value[$key]; + } + } + +} diff --git a/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php new file mode 100644 index 00000000000..8d486bb1162 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AttributeTargetsProvider.php @@ -0,0 +1,93 @@ + $attributeClass + * @return list> + */ + public function findTargetClasses(string $attributeClass): array + { + $targets = array_values(Attributes::findTargetClasses($attributeClass)); + $knownClasses = []; + foreach ($targets as $target) { + $knownClasses[strtolower($target->name)] = true; + } + + foreach ($this->discoveredTargets->targetClasses[$attributeClass] ?? [] as $target) { + if (array_key_exists(strtolower($target->name), $knownClasses)) { + continue; + } + + $targets[] = $target; + } + + /** @var list> */ + return $targets; + } + + /** + * @template T of object + * @param class-string $attributeClass + * @return list> + */ + public function findTargetMethodParameters(string $attributeClass): array + { + $targets = array_values(Attributes::findTargetMethodParameters($attributeClass)); + $knownParameters = []; + foreach ($targets as $target) { + $knownParameters[self::getParameterKey($target)] = true; + } + + foreach ($this->discoveredTargets->targetMethodParameters[$attributeClass] ?? [] as $target) { + if (array_key_exists(self::getParameterKey($target), $knownParameters)) { + continue; + } + + $targets[] = $target; + } + + /** @var list> */ + return $targets; + } + + /** + * @param TargetMethodParameter $target + */ + private static function getParameterKey(TargetMethodParameter $target): string + { + return sprintf('%s::%s $%s', strtolower($target->class), strtolower($target->method), $target->name); + } + +} diff --git a/src/DependencyInjection/AttributeServices/AutoloadRules.php b/src/DependencyInjection/AttributeServices/AutoloadRules.php new file mode 100644 index 00000000000..1190e43f1d7 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/AutoloadRules.php @@ -0,0 +1,45 @@ +> $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 new file mode 100644 index 00000000000..bbce4ce6ac4 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ComposerPackage.php @@ -0,0 +1,24 @@ + $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, + ) + { + } + + public function findPackageOfDirectory(string $directory): ?ComposerPackage + { + foreach ($this->packagesByInstallPath as $installPath => $package) { + if ($directory === $installPath || str_starts_with($directory, $installPath . '/')) { + return $package; + } + } + + 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 new file mode 100644 index 00000000000..ee93d118fe9 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ComposerProjectFactory.php @@ -0,0 +1,259 @@ +fileHelper->normalizePath($projectPath, '/'), '/'); + $vendorDirectory = rtrim($this->fileHelper->normalizePath( + ComposerHelper::getVendorDirFromComposerConfig($projectPath, $composer), + '/', + ), '/'); + + $installedJson = $this->loadInstalledJson($vendorDirectory); + $installedPackages = []; + $devInstalled = true; + if ($installedJson !== null) { + $installedPackages = $installedJson['packages'] ?? $installedJson; + $devInstalled = (bool) ($installedJson['dev'] ?? true); + } + + $versions = $this->loadInstalledVersions($vendorDirectory); + + $packagesByInstallPath = []; + if (is_array($installedPackages)) { + foreach ($installedPackages as $package) { + if (!is_array($package) || !isset($package['name']) || !is_string($package['name'])) { + continue; + } + + if (isset($package['install-path']) && is_string($package['install-path'])) { + $installPath = $vendorDirectory . '/composer/' . $package['install-path']; + } else { + $installPath = $vendorDirectory . '/' . $package['name']; + } + $installPath = rtrim($this->fileHelper->normalizePath($installPath, '/'), '/'); + + $packagesByInstallPath[$installPath] = new ComposerPackage( + $package['name'], + $this->createCacheToken($package['name'], $installPath, $vendorDirectory, $versions), + $this->extractAutoloadRules($package, 'autoload', $installPath), + ); + } + } + + // 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 array|null + */ + private function loadInstalledJson(string $vendorDirectory): ?array + { + $installedJsonPath = $vendorDirectory . '/composer/installed.json'; + if (!is_file($installedJsonPath)) { + return null; + } + + try { + $installedJson = Json::decode(FileReader::read($installedJsonPath), Json::FORCE_ARRAY); + } catch (CouldNotReadFileException | JsonException) { + return null; + } + + if (!is_array($installedJson)) { + return null; + } + + return $installedJson; + } + + /** + * @return array> + */ + private function loadInstalledVersions(string $vendorDirectory): array + { + $installedPhp = $vendorDirectory . '/composer/installed.php'; + if (!is_file($installedPhp)) { + return []; + } + + $installed = require $installedPhp; + if (!is_array($installed) || !isset($installed['versions']) || !is_array($installed['versions'])) { + return []; + } + + $versions = []; + foreach ($installed['versions'] as $package => $info) { + if (!is_string($package) || !is_array($info)) { + continue; + } + + $versions[$package] = $info; + } + + return $versions; + } + + /** + * Version identity of the package usable as a container cache key, or null when the installed + * files can change without the recorded version changing - a path repository (the install path + * escapes the vendor directory or is a symlink) or a missing reference. Null makes the resolver + * fall back to hashing the directory contents. + * + * @param array> $versions + */ + private function createCacheToken(string $packageName, string $installPath, string $vendorDirectory, array $versions): ?string + { + if (!str_starts_with($installPath, $vendorDirectory . '/')) { + return null; + } + + $realInstallPath = realpath($installPath); + if ($realInstallPath === false || rtrim($this->fileHelper->normalizePath($realInstallPath, '/'), '/') !== $installPath) { + return null; + } + + $info = $versions[$packageName] ?? null; + if ($info === null || !isset($info['pretty_version']) || !is_string($info['pretty_version'])) { + return null; + } + + if (preg_match('/[^v\d.]/', $info['pretty_version']) === 0) { + // a tagged version, see ComposerHelper::processPackageVersion() + return $info['pretty_version']; + } + + if (isset($info['reference']) && is_string($info['reference']) && $info['reference'] !== '') { + return $info['pretty_version'] . '@' . $info['reference']; + } + + 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/DiscoveredAttributeTargets.php b/src/DependencyInjection/AttributeServices/DiscoveredAttributeTargets.php new file mode 100644 index 00000000000..98e9b97377d --- /dev/null +++ b/src/DependencyInjection/AttributeServices/DiscoveredAttributeTargets.php @@ -0,0 +1,31 @@ +>> $targetClasses + * @param array>> $targetMethodParameters + */ + public function __construct( + public array $targetClasses, + public array $targetMethodParameters, + ) + { + } + + public static function createEmpty(): self + { + return new self([], []); + } + +} diff --git a/src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php b/src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php new file mode 100644 index 00000000000..f1e9f4da529 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/InvalidAttributeServicesDirectoriesException.php @@ -0,0 +1,27 @@ + $errors + */ + public function __construct(private array $errors) + { + parent::__construct(implode("\n\n", $errors)); + } + + /** + * @return non-empty-list + */ + public function getErrors(): array + { + return $this->errors; + } + +} diff --git a/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php new file mode 100644 index 00000000000..74d4459ec6b --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectories.php @@ -0,0 +1,51 @@ + $directories + */ + public function __construct(public array $directories) + { + } + + public static function createEmpty(): self + { + return new self([]); + } + + /** + * @return list + */ + public function getDirectoryPaths(): array + { + $paths = []; + foreach ($this->directories as $directory) { + $paths[] = $directory->directory; + } + + return $paths; + } + + /** + * @return array + */ + public function getCacheKeyComponent(): array + { + $component = []; + foreach ($this->directories as $directory) { + foreach ($directory->cacheKeyComponent as $key => $value) { + $component[$key] = $value; + } + } + + return $component; + } + +} diff --git a/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php new file mode 100644 index 00000000000..0af89999861 --- /dev/null +++ b/src/DependencyInjection/AttributeServices/ResolvedAttributeServicesDirectory.php @@ -0,0 +1,32 @@ +> $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/src/DependencyInjection/AttributeServicesDirectoriesExtension.php b/src/DependencyInjection/AttributeServicesDirectoriesExtension.php new file mode 100644 index 00000000000..517eb633d9a --- /dev/null +++ b/src/DependencyInjection/AttributeServicesDirectoriesExtension.php @@ -0,0 +1,26 @@ +getContainerBuilder(); - - $autowiredParameters = Attributes::findTargetMethodParameters(AutowiredParameter::class); - $constructorParameters = []; - foreach ($autowiredParameters as $parameter) { - if (strcasecmp($parameter->method, '__construct') !== 0) { - continue; - } - $lowerClass = strtolower($parameter->class); - $constructorParameters[$lowerClass] ??= []; - $constructorParameters[$lowerClass][] = $parameter; - } - - foreach (Attributes::findTargetClasses(AutowiredService::class) as $class) { - $reflection = new ReflectionClass($class->name); - $attribute = $class->attribute; - - $definition = $builder->addDefinition($attribute->name) - ->setType($class->name) - ->setAutowired($attribute->as); - - if ($attribute->factory !== null) { - [$ref, $method] = explode('::', $attribute->factory); - $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); - } - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - - if (!$attribute->autoTag) { - continue; - } - - foreach (ValidateServiceTagsExtension::getInterfaceTagMapping() as $interface => $tag) { - if (!$reflection->implementsInterface($interface)) { - continue; - } - - $definition->addTag($tag); - } - } - - foreach (Attributes::findTargetClasses(NonAutowiredService::class) as $class) { - $attribute = $class->attribute; - - $definition = $builder->addDefinition($attribute->name) - ->setType($class->name) - ->setAutowired(false); - - if ($attribute->factory !== null) { - [$ref, $method] = explode('::', $attribute->factory); - $definition->setFactory(new Statement([new Reference(substr($ref, 1)), $method])); - } - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - } - - foreach (Attributes::findTargetClasses(GenerateFactory::class) as $class) { - $attribute = $class->attribute; - $definition = $builder->addFactoryDefinition(null) - ->setImplement($attribute->interface); - - if ($attribute->resultType !== null) { - $definition->getResultDefinition()->setType($attribute->resultType); - } - - $resultDefinition = $definition->getResultDefinition(); - self::processConstructorParameters($builder, $class->name, $resultDefinition, $constructorParameters); - } - /** @var stdClass&object{level: int|null} $config */ $config = $this->getConfig(); - if ($config->level === null) { - return; - } - - foreach (Attributes::findTargetClasses(RegisteredRule::class) as $class) { - $attribute = $class->attribute; - if ($attribute->level > $config->level) { - continue; - } - - $definition = $builder->addDefinition(null) - ->setFactory($class->name) - ->setAutowired($class->name) - ->addTag(LazyRegistry::RULE_TAG); - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - } - - foreach (Attributes::findTargetClasses(RegisteredCollector::class) as $class) { - $attribute = $class->attribute; - if ($attribute->level > $config->level) { - continue; - } - - $definition = $builder->addDefinition(null) - ->setFactory($class->name) - ->setAutowired($class->name) - ->addTag(RegistryFactory::COLLECTOR_TAG); - - self::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); - } - } - - /** - * @param class-string $className - * @param array>> $constructorParameters - */ - 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); - } - $definition->setArgument($autowiredParameter->name, $argument); - } - } - - /** - * Turns a `%foo%` reference into a deferred `$this->getParameter('foo')` lookup instead of reading - * ContainerBuilder::$parameters right now. Extensions registered after this one still rewrite the - * parameters during loadConfiguration() - ValidateExcludePathsExtension unwraps OptionalPath objects - * in `excludePaths` - and a value snapshotted here would keep the pre-rewrite contents. - * - * `%foo.bar%` becomes `$this->getParameter('foo')['bar']` and `%foo%/suffix` becomes a concatenation, - * mirroring how Nette itself compiles references to dynamic parameters. - * - * @return PhpLiteral|string - * @throws ShouldNotHappenException when the reference points at a parameter that does not exist - */ - private static function createDeferredParameter(ContainerBuilder $builder, string $ref) - { - $parts = preg_split('#%([\w.-]*)%#', $ref, flags: PREG_SPLIT_DELIM_CAPTURE); - if ($parts === false) { - throw new ShouldNotHappenException(); - } - - $dumper = new Dumper(); - $lookups = []; - $pieces = []; - $withoutReferences = ''; - foreach ($parts as $i => $part) { - if ($i % 2 === 0) { - if ($part !== '') { - $pieces[] = $dumper->dump($part); - $withoutReferences .= $part; - } - continue; - } - - if ($part === '') { - // '%%' is an escaped percent sign - $pieces[] = $dumper->dump('%'); - $withoutReferences .= '%'; - continue; - } - - $keys = explode('.', $part); - self::checkParameterExists($builder, $part, $keys); - - $code = $dumper->format('$this->getParameter(?)', $keys[0]); - foreach (array_slice($keys, 1) as $key) { - $code .= sprintf('[%s]', $dumper->dump($key)); - } - - $lookups[] = $code; - $pieces[] = sprintf('(%s)', $code); - } - - if (count($lookups) === 0) { - return $withoutReferences; - } - - if (count($pieces) === 1) { - // the reference is the whole value, no string coercion - return ContainerBuilder::literal($lookups[0]); - } - - return ContainerBuilder::literal(implode(' . ', $pieces)); - } - - /** - * The values are resolved at runtime but the keys never change after loadConfiguration(), - * so a typo in a reference is still caught while compiling the container. - * - * @param non-empty-list $keys - * @throws ShouldNotHappenException - */ - private static function checkParameterExists(ContainerBuilder $builder, string $ref, array $keys): void - { - $value = $builder->parameters; - foreach ($keys as $key) { - if (!is_array($value)) { - // a dynamic parameter - cannot be traversed while compiling - return; - } - if (!array_key_exists($key, $value)) { - throw new ShouldNotHappenException(sprintf("Missing parameter '%s'.", $ref)); - } - - $value = $value[$key]; - } + AttributeServicesRegistrar::registerServices( + $this->getContainerBuilder(), + AttributeTargetsProvider::create(), + $config->level, + ); } } diff --git a/src/DependencyInjection/AutowiredParameter.php b/src/DependencyInjection/AutowiredParameter.php index d8547af2f86..8926f493434 100644 --- a/src/DependencyInjection/AutowiredParameter.php +++ b/src/DependencyInjection/AutowiredParameter.php @@ -12,6 +12,12 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on constructor parameters + * of classes in directories listed in the `attributeServicesDirectories` section + * of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_PARAMETER)] final class AutowiredParameter diff --git a/src/DependencyInjection/AutowiredService.php b/src/DependencyInjection/AutowiredService.php index 85778b23fa4..da8f2715af7 100644 --- a/src/DependencyInjection/AutowiredService.php +++ b/src/DependencyInjection/AutowiredService.php @@ -13,6 +13,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class AutowiredService diff --git a/src/DependencyInjection/Configurator.php b/src/DependencyInjection/Configurator.php index 49cc9f546df..385a7127355 100644 --- a/src/DependencyInjection/Configurator.php +++ b/src/DependencyInjection/Configurator.php @@ -39,6 +39,9 @@ final class Configurator extends \Nette\Bootstrap\Configurator /** @var string[] */ private array $allConfigFiles = []; + /** @var array */ + private array $attributeServicesDirectoriesCacheKey = []; + public function __construct(private LoaderFactory $loaderFactory, private bool $journalContainer) { parent::__construct(); @@ -58,6 +61,14 @@ public function setAllConfigFiles(array $allConfigFiles): void $this->allConfigFiles = $allConfigFiles; } + /** + * @param array $cacheKeyComponent + */ + public function setAttributeServicesDirectoriesCacheKey(array $cacheKeyComponent): void + { + $this->attributeServicesDirectoriesCacheKey = $cacheKeyComponent; + } + /** * @return mixed[] */ @@ -103,6 +114,7 @@ public function loadContainer(): string is_file($attributesPhp) ? hash_file('sha256', $attributesPhp) : 'attributes-missing', NeonAdapter::CACHE_KEY, $this->getAllConfigFilesHashes(), + $this->attributeServicesDirectoriesCacheKey, ]; $className = $loader->load( diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index b2d2e6dc4cd..27c28dfd16e 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -23,6 +23,8 @@ use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; use PHPStan\Command\CommandHelper; use PHPStan\Command\Environment; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesDirectoriesResolver; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesDiscoveryContext; use PHPStan\File\FileHelper; use PHPStan\Node\Printer\Printer; use PHPStan\Php\PhpVersion; @@ -122,6 +124,10 @@ public function create( ], ); + $directoriesResolver = new AttributeServicesDirectoriesResolver($this->fileHelper, $composerAutoloaderProjectPaths); + $attributeServicesDirectories = $directoriesResolver->resolve($projectConfig['attributeServicesDirectories'] ?? []); + AttributeServicesDiscoveryContext::set($attributeServicesDirectories); + $configurator = new Configurator(new LoaderFactory( $this->fileHelper, $this->rootDirectory, @@ -147,6 +153,7 @@ public function create( 'generateBaselineFile' => $generateBaselineFile, 'usedLevel' => $usedLevel, 'cliAutoloadFile' => $cliAutoloadFile, + 'attributeServicesDirectories' => $attributeServicesDirectories->getDirectoryPaths(), 'env' => Environment::getCleanedArray(), ], $additionalParameters)); $configurator->addDynamicParameters([ @@ -161,6 +168,7 @@ public function create( } $configurator->setAllConfigFiles($allConfigFiles); + $configurator->setAttributeServicesDirectoriesCacheKey($attributeServicesDirectories->getCacheKeyComponent()); $container = $configurator->createContainer()->getByType(Container::class); $this->validateParameters($container->getParameters(), $projectConfig['parametersSchema']); @@ -240,7 +248,9 @@ private function detectDuplicateIncludedFiles( array $loaderParameters, ): array { - $neonAdapter = new NeonCachedFileReader([]); + // attributeServicesDirectories must come out absolutized relative to the declaring file + // even in this pre-pass - ContainerFactory reads the section before the container compiles + $neonAdapter = new NeonCachedFileReader(['[attributeServicesDirectories][]']); $phpAdapter = new PhpAdapter(); $allConfigFiles = []; $configArray = []; diff --git a/src/DependencyInjection/GenerateFactory.php b/src/DependencyInjection/GenerateFactory.php index c4b6a59ce03..db7578708a5 100644 --- a/src/DependencyInjection/GenerateFactory.php +++ b/src/DependencyInjection/GenerateFactory.php @@ -15,6 +15,11 @@ * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class GenerateFactory diff --git a/src/DependencyInjection/NeonAdapter.php b/src/DependencyInjection/NeonAdapter.php index 1f8f93db5c2..0b87675d879 100644 --- a/src/DependencyInjection/NeonAdapter.php +++ b/src/DependencyInjection/NeonAdapter.php @@ -30,7 +30,7 @@ final class NeonAdapter implements Adapter { - public const CACHE_KEY = 'v32-deferred-autowired-parameters'; + public const CACHE_KEY = 'v33-attribute-services-directories'; private const PREVENT_MERGING_SUFFIX = '!'; diff --git a/src/DependencyInjection/NonAutowiredService.php b/src/DependencyInjection/NonAutowiredService.php index d9891975027..08c6a840d46 100644 --- a/src/DependencyInjection/NonAutowiredService.php +++ b/src/DependencyInjection/NonAutowiredService.php @@ -6,9 +6,14 @@ /** * Registers a non-autowired named service in the DI container. - + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class NonAutowiredService diff --git a/src/DependencyInjection/RegisteredCollector.php b/src/DependencyInjection/RegisteredCollector.php index 42af140bfcf..eff1f04d86e 100644 --- a/src/DependencyInjection/RegisteredCollector.php +++ b/src/DependencyInjection/RegisteredCollector.php @@ -7,8 +7,16 @@ /** * Registers a collector in the DI container on the set rule level. * + * The collector is active when the analysis runs on this level or higher. + * Level 0 means the collector is always active. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class RegisteredCollector diff --git a/src/DependencyInjection/RegisteredRule.php b/src/DependencyInjection/RegisteredRule.php index 3bb80348d50..25fc94a0d10 100644 --- a/src/DependencyInjection/RegisteredRule.php +++ b/src/DependencyInjection/RegisteredRule.php @@ -7,8 +7,17 @@ /** * Registers a rule in the DI container on the set rule level. * + * The rule is active when the analysis runs on this level or higher. + * Level 0 means the rule is always active - the equivalent + * of registering the rule in the `rules` section. + * * Works thanks to https://github.com/ondrejmirtes/composer-attribute-collector * and AutowiredAttributeServicesExtension. + * + * Extensions and analysed projects can use this attribute on classes in directories + * listed in the `attributeServicesDirectories` section of their configuration file. + * + * @api */ #[Attribute(flags: Attribute::TARGET_CLASS)] final class RegisteredRule diff --git a/src/DependencyInjection/StubValidatorRuleServicesExtension.php b/src/DependencyInjection/StubValidatorRuleServicesExtension.php index f737d202dfd..3922a7e8358 100644 --- a/src/DependencyInjection/StubValidatorRuleServicesExtension.php +++ b/src/DependencyInjection/StubValidatorRuleServicesExtension.php @@ -3,11 +3,10 @@ namespace PHPStan\DependencyInjection; use Nette\DI\CompilerExtension; -use olvlvl\ComposerAttributeCollector\Attributes; use Override; +use PHPStan\DependencyInjection\AttributeServices\AttributeServicesRegistrar; +use PHPStan\DependencyInjection\AttributeServices\AttributeTargetsProvider; use PHPStan\PhpDoc\StubValidator; -use function strcasecmp; -use function strtolower; final class StubValidatorRuleServicesExtension extends CompilerExtension { @@ -15,27 +14,17 @@ final class StubValidatorRuleServicesExtension extends CompilerExtension #[Override] public function loadConfiguration(): void { - require_once __DIR__ . '/../../vendor/attributes.php'; $builder = $this->getContainerBuilder(); + $targets = AttributeTargetsProvider::create(); + $constructorParameters = AttributeServicesRegistrar::collectConstructorParameters($targets); - $autowiredParameters = Attributes::findTargetMethodParameters(AutowiredParameter::class); - $constructorParameters = []; - foreach ($autowiredParameters as $parameter) { - if (strcasecmp($parameter->method, '__construct') !== 0) { - continue; - } - $lowerClass = strtolower($parameter->class); - $constructorParameters[$lowerClass] ??= []; - $constructorParameters[$lowerClass][] = $parameter; - } - - foreach (Attributes::findTargetClasses(ValidatesStubFiles::class) as $class) { + foreach ($targets->findTargetClasses(ValidatesStubFiles::class) as $class) { $definition = $builder->addDefinition(null) ->setFactory($class->name) ->setAutowired(false) ->addTag(StubValidator::SERVICE_RULE_TAG); - AutowiredAttributeServicesExtension::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); + AttributeServicesRegistrar::processConstructorParameters($builder, $class->name, $definition, $constructorParameters); } } diff --git a/src/Rules/Api/ApiAttributeRule.php b/src/Rules/Api/ApiAttributeRule.php new file mode 100644 index 00000000000..5ecc00ade4d --- /dev/null +++ b/src/Rules/Api/ApiAttributeRule.php @@ -0,0 +1,69 @@ + + */ +#[RegisteredRule(level: 0)] +final class ApiAttributeRule implements Rule +{ + + public function __construct( + private ApiRuleHelper $apiRuleHelper, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return Attribute::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $attributeClassName = $scope->resolveName($node->name); + if (!$this->reflectionProvider->hasClass($attributeClassName)) { + return []; + } + + $attributeClassReflection = $this->reflectionProvider->getClass($attributeClassName); + if (!$this->apiRuleHelper->isPhpStanCode($scope, $attributeClassReflection->getName(), $attributeClassReflection->getFileName())) { + return []; + } + + $ruleError = RuleErrorBuilder::message(sprintf( + 'Using attribute %s is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + $attributeClassReflection->getDisplayName(), + ))->identifier('phpstanApi.attribute')->tip(sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ))->build(); + + $docBlock = $attributeClassReflection->getResolvedPhpDoc(); + if ($docBlock === null) { + return [$ruleError]; + } + + foreach ($docBlock->getPhpDocNodes() as $phpDocNode) { + $apiTags = $phpDocNode->getTagsByName('@api'); + if (count($apiTags) > 0) { + return []; + } + } + + return [$ruleError]; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php new file mode 100644 index 00000000000..ec037308142 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/Neon2AttributesAnalyzerTest.php @@ -0,0 +1,50 @@ +analyze(__DIR__ . '/data/convert.neon'); + + $this->assertSame([ + ['rules', 0, 'Neon2AttributesFixtures\ConvFixtureRule', '#[RegisteredRule(level: 0)]'], + ['services', 0, 'Neon2AttributesFixtures\ConvFixtureService', '#[AutowiredService]'], + ['services', 1, 'Neon2AttributesFixtures\ConvFixtureExtension', '#[AutowiredService]'], + ['services', 2, 'Neon2AttributesFixtures\ConvFixtureUntaggedExtension', '#[AutowiredService(autoTag: false)]'], + ], array_map( + static fn (ServiceConversion $conversion): array => [$conversion->section, $conversion->entryIndex, $conversion->className, $conversion->attributeCode], + $plan->conversions, + )); + + $this->assertSame( + [ + 'tmpDir' => '#[AutowiredParameter]', + 'level' => "#[AutowiredParameter(ref: '%usedLevel%')]", + ], + $plan->conversions[1]->parameterAttributes, + ); + + $this->assertSame([ + ['PHPStan\File\FileHelper', 'The class already carries the PHPStan\DependencyInjection\AutowiredService attribute.'], + ['Nette\Neon\Neon', 'The class is not part of this project.'], + ['Neon2AttributesFixtures\ConvFixtureService', 'The service definition uses `setup` which cannot be expressed with an attribute.'], + ], array_map( + static fn (SkippedEntry $skipped): array => [$skipped->description, $skipped->reason], + $plan->skipped, + )); + + // the fixtures are autoloaded through the autoload-dev classmap rule covering tests/PHPStan + $this->assertSame(['../../..'], $plan->directoriesToDeclare); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php b/tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php new file mode 100644 index 00000000000..ec80d29252f --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/NeonEditorTest.php @@ -0,0 +1,91 @@ +removeEntries(self::NEON, 'rules', [0], 2); + $result = $editor->removeEntries($result, 'services', [0, 2], 3); + + $this->assertSame(<<<'NEON' +parameters: + level: 0 + +rules: + - Foo\SecondRule + +services: + named: + class: Foo\NamedService +NEON, $result); + } + + public function testRemoveWholeSection(): void + { + $editor = new NeonEditor(); + $result = $editor->removeEntries(self::NEON, 'rules', [0, 1], 2); + + $this->assertSame(<<<'NEON' +parameters: + level: 0 + +services: + - + class: Foo\FirstService + arguments: + - %tmpDir% + named: + class: Foo\NamedService + - Foo\ThirdService +NEON, $result); + } + + public function testEntryCountMismatchAborts(): void + { + $editor = new NeonEditor(); + $this->expectException(Neon2AttributesException::class); + $this->expectExceptionMessage('Cannot map the `rules` section onto the file'); + $editor->removeEntries(self::NEON, 'rules', [0], 3); + } + + public function testAddDirectoriesSection(): void + { + $editor = new NeonEditor(); + $result = $editor->addDirectoriesSection("parameters:\n\tlevel: 0\n", ['src']); + + $this->assertSame("attributeServicesDirectories:\n\t- src\n\nparameters:\n\tlevel: 0\n", $result); + } + + public function testAddDirectoriesToExistingSection(): void + { + $editor = new NeonEditor(); + $result = $editor->addDirectoriesSection("attributeServicesDirectories:\n\t- src\n\nparameters:\n\tlevel: 0\n", ['src', 'rules']); + + $this->assertSame("attributeServicesDirectories:\n\t- src\n\t- rules\n\nparameters:\n\tlevel: 0\n", $result); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php b/tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php new file mode 100644 index 00000000000..baae17cbbf6 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/PhpAttributeInserterTest.php @@ -0,0 +1,185 @@ + '#[AutowiredParameter]'], + ['PHPStan\DependencyInjection\AutowiredService', 'PHPStan\DependencyInjection\AutowiredParameter'], + ); + + $this->assertSame(<<<'PHP' +insert($content, [$conversion])); + } + + public function testInsertWithoutUseBlock(): void + { + $content = <<<'PHP' +assertSame(<<<'PHP' +insert($content, [$conversion])); + } + + public function testShortNameConflictFallsBackToFullyQualifiedForm(): void + { + $content = <<<'PHP' +assertSame(<<<'PHP' +insert($content, [$conversion])); + } + + public function testParameterSharingLineAborts(): void + { + $content = <<<'PHP' + '#[AutowiredParameter]'], + ['PHPStan\DependencyInjection\AutowiredService', 'PHPStan\DependencyInjection\AutowiredParameter'], + ); + + $this->expectException(Neon2AttributesException::class); + $this->expectExceptionMessage('does not start its own line'); + (new PhpAttributeInserter())->insert($content, [$conversion]); + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php new file mode 100644 index 00000000000..7ffbf4ea37c --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureExtension.php @@ -0,0 +1,26 @@ + + */ +final class ConvFixtureRule 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/ConvFixtureService.php b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureService.php new file mode 100644 index 00000000000..9ab6e756c2b --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureService.php @@ -0,0 +1,20 @@ +tmpDir . $this->level; + } + +} diff --git a/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php new file mode 100644 index 00000000000..e5015527364 --- /dev/null +++ b/tests/PHPStan/Command/Neon2Attributes/data/ConvFixtureUntaggedExtension.php @@ -0,0 +1,26 @@ +assertNull($resolver->resolvePackage('/outside/the/project/File.php')); } + public function testResolveDirectoryPackage(): void + { + $fixtureRoot = __DIR__ . '/data/package-resolver'; + $fileHelper = self::getContainer()->getByType(FileHelper::class); + $resolver = new PackageDependencyResolver([$fixtureRoot], $fileHelper); + + // Unlike resolvePackage(), the install path itself matches too. + $this->assertSame('acme/widget', $resolver->resolveDirectoryPackage($fixtureRoot . '/vendor/acme/widget')); + $this->assertSame('acme/widget', $resolver->resolveDirectoryPackage($fixtureRoot . '/vendor/acme/widget/src')); + + $this->assertNull($resolver->resolveDirectoryPackage($fixtureRoot . '/src')); + $this->assertNull($resolver->resolveDirectoryPackage('/outside/the/project')); + } + public function testExtractComposerPackageVersions(): void { $resolver = new PackageDependencyResolver([], self::getContainer()->getByType(FileHelper::class)); diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php new file mode 100644 index 00000000000..d48470b9356 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesDirectoriesResolverTest.php @@ -0,0 +1,169 @@ +createResolver(); + $this->assertSame([], $resolver->resolve(null)->directories); + $this->assertSame([], $resolver->resolve([])->directories); + } + + public function testPhpVersionGate(): void + { + $resolver = $this->createResolver(70428); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('The attributeServicesDirectories section requires PHP 8.0 or later, PHPStan is running on PHP 7.4.28.'); + $resolver->resolve([__DIR__]); + } + + public function testNonListSection(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('The attributeServicesDirectories section must contain a list of directory paths.'); + $resolver->resolve('src'); + } + + public function testNonStringEntry(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('The attributeServicesDirectories section must contain a list of directory paths.'); + $resolver->resolve([['src']]); + } + + public function testParameterEntryRejected(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('Entry %rootDir%/src in the attributeServicesDirectories section must be a plain path - % parameters are not supported.'); + $resolver->resolve(['%rootDir%/src']); + } + + public function testWildcardEntryRejected(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('Entry */src in the attributeServicesDirectories section must be a plain path - wildcards are not supported.'); + $resolver->resolve(['*/src']); + } + + public function testMissingDirectory(): void + { + $resolver = $this->createResolver(); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('does not exist'); + $resolver->resolve([__DIR__ . '/does-not-exist']); + } + + public function testDirectoryOutsideComposerProjects(): void + { + $resolver = new AttributeServicesDirectoriesResolver(new FileHelper(self::getRepoRoot()), []); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage('is not inside any project with Composer metadata known to PHPStan'); + $resolver->resolve([__DIR__]); + } + + public function testProjectOwnPsr4Directory(): void + { + $repoRoot = self::getRepoRoot(); + $directory = $repoRoot . '/src/DependencyInjection/AttributeServices'; + $resolved = $this->createResolver()->resolve([$directory]); + + $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\\']); + + $expectedFile = $directory . '/AttributeServicesDirectoriesResolver.php'; + $this->assertArrayHasKey($expectedFile, $resolvedDirectory->cacheKeyComponent); + $this->assertSame(hash_file('sha256', $expectedFile), $resolvedDirectory->cacheKeyComponent[$expectedFile]); + } + + 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(); + $directory = $repoRoot . '/vendor/nikic/php-parser/lib/PhpParser'; + $resolved = $this->createResolver()->resolve([$directory]); + + $this->assertCount(1, $resolved->directories); + $resolvedDirectory = $resolved->directories[0]; + $this->assertSame('nikic/php-parser', $resolvedDirectory->packageName); + + $this->assertSame([$directory], array_keys($resolvedDirectory->cacheKeyComponent)); + $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(); + $resolved = $this->createResolver()->resolve([ + $repoRoot . '/src/DependencyInjection/AttributeServices', + $repoRoot . '/src/DependencyInjection', + ]); + + $this->assertCount(1, $resolved->directories); + $this->assertSame($repoRoot . '/src/DependencyInjection', $resolved->directories[0]->directory); + } + + public function testAllErrorsAreCollected(): void + { + $resolver = $this->createResolver(); + try { + $resolver->resolve([ + __DIR__ . '/does-not-exist', + self::getRepoRoot() . '/bin', + ]); + $this->fail('Expected InvalidAttributeServicesDirectoriesException.'); + } catch (InvalidAttributeServicesDirectoriesException $e) { + $this->assertCount(2, $e->getErrors()); + } + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php new file mode 100644 index 00000000000..33a367e3f86 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/AttributeServicesIntegrationTest.php @@ -0,0 +1,161 @@ +create(self::$tmpDir, [__DIR__ . '/attributeServices.neon'], [], [dirname(__DIR__, 4)]); + } + + private static function createTmpDir(): string + { + $tmpDir = sys_get_temp_dir() . '/phpstan-attribute-services-' . md5(uniqid(more_entropy: true)); + mkdir($tmpDir, 0777, true); + + return $tmpDir; + } + + public function testDiscoveredServiceWithAutowiredParameters(): void + { + $container = self::createContainer(); + $service = $container->getByType(DiscoveredService::class); + $this->assertSame(__DIR__, $service->getCurrentWorkingDirectory()); + $this->assertSame(self::$tmpDir, $service->getTmpDir()); + } + + public function testDiscoveredServiceIsAutoTagged(): void + { + $container = self::createContainer(); + $extensions = $container->getExtensionsCollection(ReadWritePropertiesExtension::class)->getAll(); + $this->assertCount(1, $extensions); + $this->assertInstanceOf(DiscoveredService::class, $extensions[0]); + } + + public function testDiscoveredNamedService(): void + { + $container = self::createContainer(); + $this->assertInstanceOf(DiscoveredNamedService::class, $container->getService('attributeServicesFixtures.named')); + } + + public function testDiscoveredNonAutowiredService(): void + { + $container = self::createContainer(); + $this->assertInstanceOf(DiscoveredNonAutowiredService::class, $container->getService('attributeServicesFixtures.nonAutowired')); + } + + public function testDiscoveredRulesFollowTheLevel(): void + { + $container = self::createContainer(); + $rules = $container->getServicesByTag(LazyRegistry::RULE_TAG); + $this->assertCount(1, array_filter($rules, static fn ($rule): bool => $rule instanceof DiscoveredRuleLevelThree)); + $this->assertCount(0, array_filter($rules, static fn ($rule): bool => $rule instanceof DiscoveredRuleLevelEight)); + } + + public function testDiscoveredCollector(): void + { + $container = self::createContainer(); + $collectors = $container->getServicesByTag(RegistryFactory::COLLECTOR_TAG); + $this->assertCount(1, array_filter($collectors, static fn ($collector): bool => $collector instanceof DiscoveredCollector)); + } + + public function testDiscoveredGeneratedFactory(): void + { + $container = self::createContainer(); + $factory = $container->getByType(DiscoveredValueFactory::class); + $value = $factory->create('hello'); + $this->assertSame('hello', $value->name); + $this->assertSame(self::$tmpDir, $value->tmpDir); + } + + public function testDirectoriesParameterIsAbsolutized(): void + { + $container = self::createContainer(); + $this->assertSame([__DIR__ . '/data/services'], $container->getParameter('attributeServicesDirectories')); + } + + public function testDerivativeContainerSeesDiscoveredServices(): void + { + $container = self::createContainer(); + $derivativeContainer = $container->getByType(DerivativeContainerFactory::class) + ->create([dirname(__DIR__, 4) . '/conf/config.stubValidator.neon'], ['allStubFiles' => []]); + $service = $derivativeContainer->getByType(DiscoveredService::class); + $this->assertSame(__DIR__, $service->getCurrentWorkingDirectory()); + } + + #[DataProvider('dataErrors')] + public function testErrors(string $neonFile, string $expectedMessage): void + { + $containerFactory = new ContainerFactory(__DIR__); + $this->expectException(InvalidAttributeServicesDirectoriesException::class); + $this->expectExceptionMessage($expectedMessage); + $containerFactory->create(self::createTmpDir(), [__DIR__ . '/' . $neonFile], [], [dirname(__DIR__, 4)]); + } + + /** + * @return iterable + */ + public static function dataErrors(): iterable + { + yield [ + 'containerExtension.neon', + '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.', + ]; + + yield [ + 'internalAttribute.neon', + 'Attribute #[ValidatesStubFiles] on class AttributeServicesFixtures\InternalAttribute\UsesValidatesStubFiles is only supported on classes shipped with PHPStan itself, not on classes discovered through the attributeServicesDirectories section.', + ]; + + yield [ + 'unloadable.neon', + 'cannot be autoloaded.', + ]; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon b/tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon new file mode 100644 index 00000000000..0400db39a29 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/attributeServices.neon @@ -0,0 +1,5 @@ +attributeServicesDirectories: + - data/services + +autowiredAttributeServices: + level: 5 diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon b/tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon new file mode 100644 index 00000000000..69d81e5be55 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/autowiredExtensionsParam.neon @@ -0,0 +1,2 @@ +attributeServicesDirectories: + - data/autowiredExtensions diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon b/tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon new file mode 100644 index 00000000000..3a734568639 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/containerExtension.neon @@ -0,0 +1,2 @@ +attributeServicesDirectories: + - data/containerExtension diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php new file mode 100644 index 00000000000..5e5c3197088 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/autowiredExtensions/BadAutowiredExtensionsService.php @@ -0,0 +1,24 @@ + $rules + */ + public function __construct( + #[AutowiredExtensions(of: Rule::class)] + private ExtensionsCollection $rules, + ) + { + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php new file mode 100644 index 00000000000..bfde8258ce0 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/containerExtension/BadCompilerExtension.php @@ -0,0 +1,11 @@ + + */ +#[RegisteredCollector(level: 3)] +final class DiscoveredCollector implements Collector +{ + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): ?string + { + return null; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php new file mode 100644 index 00000000000..ba1796092cf --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredNamedService.php @@ -0,0 +1,11 @@ + + */ +#[RegisteredRule(level: 8)] +final class DiscoveredRuleLevelEight 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/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php new file mode 100644 index 00000000000..f9f8ec86818 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredRuleLevelThree.php @@ -0,0 +1,27 @@ + + */ +#[RegisteredRule(level: 3)] +final class DiscoveredRuleLevelThree 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/DependencyInjection/AttributeServices/data/services/DiscoveredService.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredService.php new file mode 100644 index 00000000000..7442a401cda --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredService.php @@ -0,0 +1,48 @@ +currentWorkingDirectory; + } + + public function getTmpDir(): string + { + return $this->tmpDir; + } + + 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; + } + +} diff --git a/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php new file mode 100644 index 00000000000..dcc51755879 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/AttributeServices/data/services/DiscoveredValue.php @@ -0,0 +1,20 @@ + + */ +class ApiAttributeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ApiAttributeRule(new ApiRuleHelper(), self::createReflectionProvider()); + } + + public function testRuleInPhpStan(): void + { + $this->analyse([__DIR__ . '/data/attribute-in-phpstan.php'], []); + } + + public function testRuleOutOfPhpStan(): void + { + $tip = sprintf( + "If you think it should be covered by backward compatibility promise, open a discussion:\n %s\n\n See also:\n https://phpstan.org/developing-extensions/backward-compatibility-promise", + 'https://github.com/phpstan/phpstan/discussions', + ); + + $this->analyse([__DIR__ . '/data/attribute-out-of-phpstan.php'], [ + [ + 'Using attribute PHPStan\DependencyInjection\AutowiredExtensions is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + 23, + $tip, + ], + [ + 'Using attribute PHPStan\DependencyInjection\ContainerExtension is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + 60, + $tip, + ], + [ + 'Using attribute PHPStan\DependencyInjection\ValidatesStubFiles is not covered by backward compatibility promise. The attribute might change in a minor PHPStan version.', + 66, + $tip, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php b/tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php new file mode 100644 index 00000000000..26ec970e7ad --- /dev/null +++ b/tests/PHPStan/Rules/Api/data/attribute-in-phpstan.php @@ -0,0 +1,27 @@ += 8.0 + +namespace PHPStan\Fixture\ApiAttribute; + +use PHPStan\DependencyInjection\AutowiredExtensions; +use PHPStan\DependencyInjection\ContainerExtension; +use PHPStan\DependencyInjection\ValidatesStubFiles; +use PHPStan\Rules\Rule; + +#[ContainerExtension(name: 'inPhpStan')] +class MyExtension +{ + +} + +#[ValidatesStubFiles] +class MyStubRule +{ + + public function __construct( + #[AutowiredExtensions(of: Rule::class)] + private mixed $rules, + ) + { + } + +} diff --git a/tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php b/tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php new file mode 100644 index 00000000000..be6bc6427b4 --- /dev/null +++ b/tests/PHPStan/Rules/Api/data/attribute-out-of-phpstan.php @@ -0,0 +1,82 @@ += 8.0 + +namespace AppAttribute; + +use PHPStan\DependencyInjection\AutowiredExtensions; +use PHPStan\DependencyInjection\AutowiredParameter; +use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\ContainerExtension; +use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\DependencyInjection\NonAutowiredService; +use PHPStan\DependencyInjection\RegisteredCollector; +use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\DependencyInjection\ValidatesStubFiles; +use PHPStan\Rules\Rule; + +#[AutowiredService] +class MyService +{ + + public function __construct( + #[AutowiredParameter(ref: '%tmpDir%')] + private string $tmpDir, + #[AutowiredExtensions(of: Rule::class)] + private mixed $rules, + ) + { + } + +} + +#[NonAutowiredService(name: 'appAttribute.service')] +class MyNamedService +{ + +} + +#[RegisteredRule(level: 0)] +class MyRule +{ + +} + +#[RegisteredCollector(level: 5)] +class MyCollector +{ + +} + +interface MyFactory +{ + +} + +#[GenerateFactory(interface: MyFactory::class)] +class MyResult +{ + +} + +#[ContainerExtension(name: 'appAttribute')] +class MyExtension +{ + +} + +#[ValidatesStubFiles] +class MyStubRule +{ + +} + +#[\Attribute] +class MyCustomAttribute +{ + +} + +#[MyCustomAttribute] +class UsesCustom +{ + +}