From 8215fd4987cdaa30552ddfc80e99775aa2c3900d Mon Sep 17 00:00:00 2001 From: Julien Rosset Date: Thu, 20 Jul 2023 20:54:25 +0200 Subject: [PATCH] Code and test --- composer.json | 3 +- src/Reflection/ReflectionNamespace.php | 654 ++++++++++++++++++ tests/SubTests1/Enum1.php | 8 + tests/SubTests1/SubSubTests11/Enum11.php | 8 + tests/SubTests2/ISubTests2.php | 7 + .../SubSubTests21/SubSubTests211.php | 7 + .../SubSubTests22/SubSubTests221.php | 7 + tests/SubTests2/TSubSubTests2.php | 7 + tests/test.php | 34 + 9 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 src/Reflection/ReflectionNamespace.php create mode 100644 tests/SubTests1/Enum1.php create mode 100644 tests/SubTests1/SubSubTests11/Enum11.php create mode 100644 tests/SubTests2/ISubTests2.php create mode 100644 tests/SubTests2/SubSubTests21/SubSubTests211.php create mode 100644 tests/SubTests2/SubSubTests22/SubSubTests221.php create mode 100644 tests/SubTests2/TSubSubTests2.php create mode 100644 tests/test.php diff --git a/composer.json b/composer.json index 28c8b51..55b527f 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ }, "autoload": { "psr-4": { - "jrosset\\": "src/" + "jrosset\\": "src/", + "Tests\\": "tests/" }, "exclude-from-classmap": [ "tests/" ] }, diff --git a/src/Reflection/ReflectionNamespace.php b/src/Reflection/ReflectionNamespace.php new file mode 100644 index 0000000..cd4f272 --- /dev/null +++ b/src/Reflection/ReflectionNamespace.php @@ -0,0 +1,654 @@ +name = self::normalizeNamespaceName($name); + + if (!is_array($directories)) { + $directories = [$directories]; + } + $this->setDirectories($directories); + } + + /** + * Create a reflection on a namespace from a mapping of {namespace} => {directories} + * + * @param string $name The namespace name + * @param array $mapping The mapping + * + * @return static The namespace reflection + * + * @throws ReflectionException If the namespace can not be found with the mapping + */ + public static function createFromMapping (string $name, array $mapping): static { + $nsReflection = new static ($name, []); + + //region Normalize the mapping + $normalizedMapping = []; + foreach ($mapping as $mapNamespace => $mapDirectories) { + $normalizedMapping[self::normalizeNamespaceName($mapNamespace)] = static::normalizeDirectoriesPath($mapDirectories); + } + //endregion + //region Get the map matching this namespace + $namespaceParts = explode('\\', $nsReflection->getName()); + $namespacePartsDiscarded = []; + + $map = null; + while (count($namespaceParts) > 0) { + $namespaceSearch = implode('\\', $namespaceParts); + if (!isset($normalizedMapping[$namespaceSearch])) { + $namespacePartsDiscarded[] = array_pop($namespaceParts); + continue; + } + + $map = $normalizedMapping[$namespaceSearch]; + break; + } + + if ($map === null || count($map) === 0) { + throw new ReflectionException('Unable to identify namespace directories'); + } + $mapDirectoriesSuffix = implode(DIRECTORY_SEPARATOR, array_reverse($namespacePartsDiscarded)); + //endregion + //region Check each directory of the map + $nsDirectories = []; + foreach ($map as $mapDirectory) { + $mapDirectory .= DIRECTORY_SEPARATOR . $mapDirectoriesSuffix; + if (!file_exists($mapDirectory) || !is_dir($mapDirectory) || !is_readable($mapDirectory)) { + continue; + } + + $nsDirectories[] = $mapDirectory; + } + + if (count($nsDirectories) === 0) { + throw new ReflectionException('Unable to identify namespace directories'); + } + //endregion + + return $nsReflection->setDirectories($nsDirectories); + } + /** + * Create a reflection on a namespace from the {@link https://getcomposer.org/ composer} mapping (PSR-0 and PSR-4) + * + * @param string $name The namespace name + * @param string $vendorDirectoryPath The path to the “vendor” directory + * + * @return static The namespace reflection + * + * @throws ReflectionException If the namespace can not be found + */ + public static function createFromComposerMapping (string $name, string $vendorDirectoryPath): static { + $composerDirectory = static::normalizeDirectoryPath($vendorDirectoryPath) . DIRECTORY_SEPARATOR . 'composer'; + if (!file_exists($composerDirectory) || !is_dir($vendorDirectoryPath) || !is_readable($vendorDirectoryPath)) { + throw new ReflectionException('Unable to find composer directory: ' . $composerDirectory); + } + + $composerDirectory .= DIRECTORY_SEPARATOR; + $composerFiles = [ + $composerDirectory . 'autoload_psr4.php', // PSR-4 + ]; + + $mapping = []; + foreach ($composerFiles as $composerFile) { + if (!file_exists($composerFile)) { + continue; + } + $mapping = array_merge_recursive( + $mapping, + require($composerFile) + ); + } + + return static::createFromMapping( + $name, + $mapping + ); + } + + /** + * The namespace name + * + * @return string The namespace name + */ + public function getName (): string { + return $this->name; + } + /** + * The namespace short name, without parent namespaces + * + * @return string The namespace short name + */ + public function getShortName (): string { + $name = $this->getName(); + if (($pos = mb_strrpos($name, '\\')) !== false) { + $name = mb_substr($name, $pos + 1); + } + return $name; + } + + /** + * The parent namespace name or Null if there is no parent + * + * @return string|null The parent namespace name or Null if there is no parent + */ + public function getParentName (): ?string { + if (($pos = mb_strrpos($this->getName(), '\\')) === false) { + return null; + } + + return mb_substr($this->getName(), 0, $pos); + } + /** + * The parent namespace or Null if there is no parent + * + * WARNING: There is a high probability this method return a {@see ReflectionException} but with invalid directories, breaking his behavior. + * I recommend using a createFromXXX method with {@see static::getParentName()} + * + * @return static|null The parent namespace or Null if there is no parent + */ + public function getParent (): ?static { + if (($parentNamespaceName = $this->getParentName()) === null) { + return null; + } + + $parentNamespaceDirectories = []; + foreach ($this->getDirectories() as $directory) { + if (($parentNamespaceDirectory = basename($directory)) === '') { + continue; + } + $parentNamespaceDirectories[] = $parentNamespaceDirectory; + } + if (count($parentNamespaceDirectories) === 0) { + return null; + } + + return new static ($parentNamespaceName, $parentNamespaceDirectories); + } + + /** + * Get a sub namespace of the namespace + * + * @param string $subNamespaceName The sub namespace name + * + * @return static The sub namespace + * + * @throws ReflectionException If the sub namespace doesn't exist + */ + public function getSubNamespace (string $subNamespaceName): static { + $this->checkValidDirectories(); + + $subNamespaceDirectories = []; + foreach ($this->getDirectories() as $directory) { + $subNamespaceDirectory = $directory . DIRECTORY_SEPARATOR . $subNamespaceName; + if (!file_exists($subNamespaceDirectory) || !is_dir($subNamespaceDirectory) || !is_readable($subNamespaceDirectory)) { + continue; + } + + $subNamespaceDirectories[] = $subNamespaceDirectory; + } + if (count($subNamespaceDirectories) === 0) { + throw new ReflectionException('Unable to find namespace "' . $this->getName() . '\\' . $subNamespaceName . '"'); + } + + return new static ($this->getName() . '\\' . $subNamespaceName, $subNamespaceDirectories); + } + /** + * Get the sub namespaces of the namespace + * + * @param bool $recursive Search also in sub-sub namespaces + * + * @return static[] The list of subs namespaces + * + * @throws ReflectionException If the namespace has no configured directories + */ + public function getSubNamespaces (bool $recursive = false): array { + $this->checkValidDirectories(); + + /** @var ReflectionNamespace[] $subNamespaces */ + $subNamespaces = []; + + //region For each directory of the namespace + foreach ($this->getDirectories() as $directory) { + $directoryIterator = new FilesystemIterator($directory, FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS); + + //region For each file of the current directory + foreach ($directoryIterator as $directoryEntry) { + //region Ignore hidden files (start with a dot) + if (mb_substr($directoryEntry->getFilename(), 0, 1) === '.') { + continue; + } + //endregion + //region Ignore elements that are not directories + if (!$directoryEntry->isDir()) { + continue; + } + //endregion + + $subNamespaceReflection = new static ($this->getName() . '\\' . $directoryEntry->getFilename(), [$directoryEntry->getPathname()]); + + //region Treat recursivity + if ($recursive) { + $subNamespaces = array_merge( + $subNamespaces, + $subNamespaceReflection->getSubNamespaces(true) + ); + } + //endregion } + + if (isset($subNamespaces[$subNamespaceReflection->getName()])) { + $existingSubNamespace = $subNamespaces[$subNamespaceReflection->getName()]; + $existingSubNamespace->setDirectories( + array_unique( + array_merge( + $existingSubNamespace->getDirectories(), + $subNamespaceReflection->getDirectories() + ) + ) + ); + } + else { + $subNamespaces[$subNamespaceReflection->getName()] = $subNamespaceReflection; + } + } + //endregion + } + //endregion + + return $subNamespaces; + } + + /** + * Get a class of the namespace + * + * @param string $className The class name + * + * @return ReflectionClass The reflection class + * + * @throws ReflectionException If the class doesn't exist or is not a class + */ + public function getClass (string $className): ReflectionClass { + $reflection = new ReflectionClass($this->getName() . '\\' . $className); + if ($reflection->isInterface() || $reflection->isTrait()) { + throw new ReflectionException('The "' . $this->getName() . '\\' . $className . '" is not a class'); + } + return $reflection; + } + /** + * Get the classes of the namespace + * + * @param int|null $filters The filters, separated with "|". + *
All possibles filters: {@see static::CLASS_IS_NOT_ABSTRACT}, {@see static::CLASS_IS_NOT_FINAL} + * @param bool $recursive Search also in sub namespaces + * + * @return ReflectionClass[] The list of classes + * + * @throws ReflectionException If the namespace has no configured directories + */ + public function getClasses (?int $filters = null, bool $recursive = false): array { + $filters ??= 0; + $classes = []; + + //region For each valid element of the namespace + foreach ($this->getValidElements($recursive) as $element) { + //region Try to get the reflection class + try { + $classReflection = new ReflectionClass($this->getName() . '\\' . $element); + } + catch (ReflectionException) { + continue; + } + //endregion + //region Check it is a class (ignore interfaces and traits) + if ($classReflection->isInterface() || $classReflection->isTrait() || $classReflection->isEnum()) { + continue; + } + //endregion + //region Check filters + if (($filters & static::CLASS_IS_NOT_ABSTRACT) === static::CLASS_IS_NOT_ABSTRACT && $classReflection->isAbstract()) { + continue; + } + if (($filters & static::CLASS_IS_NOT_FINAL) === static::CLASS_IS_NOT_FINAL && $classReflection->isFinal()) { + continue; + } + //endregion + + $classes[] = $classReflection; + } + //endregion + + return $classes; + } + + /** + * Get an interface of the namespace + * + * @param string $interfaceName The interface name + * + * @return ReflectionClass The reflection interface + * + * @throws ReflectionException If the interface doesn't exist or is not an interface + */ + public function getInterface (string $interfaceName): ReflectionClass { + $reflection = new ReflectionClass($this->getName() . '\\' . $interfaceName); + if (!$reflection->isInterface()) { + throw new ReflectionException('The "' . $this->getName() . '\\' . $interfaceName . '" is not an interface'); + } + return $reflection; + } + /** + * Get the interfaces of the namespace + * + * @param bool $recursive Search also in sub namespaces + * + * @return ReflectionClass[] The list of interfaces + * + * @throws ReflectionException If the namespace has no configured directories + */ + public function getInterfaces (bool $recursive = false): array { + $interfaces = []; + + //region For each valid element of the namespace + foreach ($this->getValidElements($recursive) as $element) { + //region Try to get the reflection class + try { + $interfaceReflection = new ReflectionClass($this->getName() . '\\' . $element); + } + catch (ReflectionException) { + continue; + } + //endregion + //region Check it is an interface + if (!$interfaceReflection->isInterface()) { + continue; + } + //endregion + + $interfaces[] = $interfaceReflection; + } + //endregion + + return $interfaces; + } + + /** + * Get a trait of the namespace + * + * @param string $traitName The trait name + * + * @return ReflectionClass The reflection trait + * + * @throws ReflectionException If the trait doesn't exist or is not a trait + */ + public function getTrait (string $traitName): ReflectionClass { + $reflection = new ReflectionClass($this->getName() . '\\' . $traitName); + if (!$reflection->isTrait()) { + throw new ReflectionException('The "' . $this->getName() . '\\' . $traitName . '" is not a trait'); + } + return $reflection; + } + /** + * Get the traits of the namespace + * + * @param bool $recursive Search also in sub namespaces + * + * @return ReflectionClass[] The list of traits + * + * @throws ReflectionException If the namespace has no configured directories + */ + public function getTraits (bool $recursive = false): array { + $traits = []; + + //region For each valid element of the namespace + foreach ($this->getValidElements($recursive) as $element) { + //region Try to get the reflection class + try { + $traitReflection = new ReflectionClass($this->getName() . '\\' . $element); + } + catch (ReflectionException) { + continue; + } + //endregion + //region Check it is a trait + if (!$traitReflection->isTrait()) { + continue; + } + //endregion + + $traits[] = $traitReflection; + } + //endregion + + return $traits; + } + + /** + * Get an enum of the namespace + * + * @param string $enumName The enum name + * + * @return ReflectionEnum The reflection enum + * + * @throws ReflectionException If the enum doesn't exist + */ + public function getEnum (string $enumName): ReflectionEnum { + return new ReflectionEnum($this->getName() . '\\' . $enumName); + } + /** + * Get the enums of the namespace + * + * @param bool $recursive Search also in sub namespaces + * + * @return ReflectionEnum[] The list of enums + * + * @throws ReflectionException If the namespace has no configured directories + */ + public function getEnums (bool $recursive = false): array { + $traits = []; + + //region For each valid element of the namespace + foreach ($this->getValidElements($recursive) as $element) { + //region Try to get the reflection class + try { + $enumReflection = new ReflectionEnum($this->getName() . '\\' . $element); + } + catch (ReflectionException) { + continue; + } + //endregion + + $traits[] = $enumReflection; + } + //endregion + + return $traits; + } + + /** + * The list of directories that maps the namespace + * + * @return string[] The list of directories that maps the namespace + */ + public function getDirectories (): array { + return $this->directories; + } + /** + * Set the list of directories that maps the namespace + * + * @param string[] $directories The list of directories that maps the namespace + */ + public function setDirectories (array $directories): static { + $this->directories = array_unique(static::normalizeDirectoriesPath($directories)); + return $this; + } + + /** + * Check if at least one directory is configured + * + * @return void + * + * @throws ReflectionException If the namespace has no configured directories + */ + protected function checkValidDirectories (): void { + if (count($this->getDirectories()) === 0) { + throw new ReflectionException('The namespace must have at least one directory configured'); + } + } + /** + * Get the valid “elements” (class, interface, trait or enum) name of the namespace + * + * @param bool $recursive Search also in sub namespaces + * + * @return string[] The list of valid elements + * + * @throws ReflectionException If the namespace has no configured directories + */ + protected function getValidElements (bool $recursive): array { + $this->checkValidDirectories(); + + $namespaceName = $this->getName(); + $elements = []; + + //region For each directory of the namespace + foreach ($this->getDirectories() as $directory) { + $directoryIterator = new FilesystemIterator($directory, FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS); + + //region For each file of the current directory + foreach ($directoryIterator as $directoryEntry) { + //region Ignore hidden files (start with a dot) + if (mb_substr($directoryEntry->getFilename(), 0, 1) === '.') { + continue; + } + //endregion + //region Treat subdirectories + if ($directoryEntry->isDir()) { + //region Recursivity disabled → ignore subdirectories + if (!$recursive) { + continue; + } + //endregion + //region PSR-4 → each subdirectory is a sub namespace → created it and get its elements + $subNamespaceReflection = new static ($this->getName() . '\\' . $directoryEntry->getFilename(), [$directoryEntry->getPathname()]); + $subNamespaceElementsPrefix = mb_substr($subNamespaceReflection->getName(), mb_strlen($namespaceName . '\\')) . '\\'; + + $elements = array_merge( + $elements, + array_map( + function (string $subNamespaceElement) use ($subNamespaceElementsPrefix): string { + return $subNamespaceElementsPrefix . $subNamespaceElement; + }, + $subNamespaceReflection->getValidElements(true) + ) + ); + unset($subNamespaceReflection); + //endregion + continue; + } + //endregion + //region Ignore elements that are not files + if (!$directoryEntry->isFile()) { + continue; + } + //endregion + + //region Check file name against PSR-4 (one class by file, file name is class name) + if (preg_match('#^\s*(?[A-Z][A-Za-z0-9_]+)\s*\.php\s*$#u', $directoryEntry->getFilename(), $match) !== 1) { + continue; + } + //endregion + $elements[] = $match['elementName']; + } + //endregion + } + //endregion + + return $elements; + } + + /** + * Normalize a namespace name : remove initial and final "\" if present + * + * @param string $name The name + * + * @return string The normalized name + */ + public static final function normalizeNamespaceName (string $name): string { + return preg_replace('#^\\\\|\\\\$#u', '', $name); + } + /** + * Normalize a directory path : ensure use of {@see DIRECTORY_SEPARATOR} and remove final separator + * + * @param string $path The path + * + * @return string The normalized path + */ + public static final function normalizeDirectoryPath (string $path): string { + return realpath( + preg_replace( + [ + '#[/\\\\]#u', + '#[/\\\\]$#u', + ], + [ + DIRECTORY_SEPARATOR, + '', + ], + $path + ) + ); + } + /** + * Normalize a list of directory path : ensure use of {@see DIRECTORY_SEPARATOR} and remove final separator + * + * @param string[] $paths The path list + * + * @return string[] The normalized path list + */ + public static final function normalizeDirectoriesPath (array $paths): array { + return array_map( + [static::class, 'normalizeDirectoryPath'], + $paths + ); + } +} \ No newline at end of file diff --git a/tests/SubTests1/Enum1.php b/tests/SubTests1/Enum1.php new file mode 100644 index 0000000..c2dfde4 --- /dev/null +++ b/tests/SubTests1/Enum1.php @@ -0,0 +1,8 @@ +getShortName() . ' (' . $tests->getName() . ')' . PHP_EOL . PHP_EOL; + +echo '===== NAMESPACES =====' . PHP_EOL; +foreach ($tests->getSubNamespaces(true) as $subNamespace) { + echo "\t - " . $subNamespace->getName() . PHP_EOL; +} + +echo '===== CLASSES =====' . PHP_EOL; +foreach ($tests->getClasses(null, true) as $class) { + echo "\t - " . $class->getName() . PHP_EOL; +} + +echo '===== INTERFACES =====' . PHP_EOL; +foreach ($tests->getInterfaces(true) as $interface) { + echo "\t - " . $interface->getName() . PHP_EOL; +} + +echo '===== TRAITS =====' . PHP_EOL; +foreach ($tests->getTraits(true) as $trait) { + echo "\t - " . $trait->getName() . PHP_EOL; +} + +echo '===== ENUMS =====' . PHP_EOL; +foreach ($tests->getEnums(true) as $enum) { + echo "\t - " . $enum->getName() . ($enum->isBacked() ? ' (backed)' : '') . PHP_EOL; +} \ No newline at end of file