diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7379ae7..f5dda22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## 0.2.1 Under development
+- Enh #0: Add `Reflector` helper for class, property, type, and attribute metadata introspection (@terabytesoftw)
+
## 0.2.0 February 25, 2024
- Enh #6: Refactor codebase to improve performance (@terabytesoftw)
diff --git a/README.md b/README.md
index aa393bb..3f15918 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
Small, focused helpers for common PHP tasks
- Convert word casing, generate passwords, and list time zones with predictable output.
+ Convert word casing, inspect metadata, generate passwords, and list time zones with predictable output.
## Features
@@ -35,7 +35,7 @@
### Installation
```bash
-composer require php-forge/helper:^0.1
+composer require php-forge/helper:^0.2
```
### Quick start
@@ -115,12 +115,32 @@ $timezones = TimeZoneList::all();
// [['timezone' => 'Pacific/Midway', 'name' => 'Pacific/Midway (UTC -11:00)', 'offset' => -39600], ...]
```
+#### Inspect class metadata with Reflector
+
+```php
+ 'Pacific/Midway', 'name' => 'Pacific/Midway (UTC -11:00)', 'offset' => -39600], ...]
+```
+
+## Inspect class metadata with Reflector
+
+```php
+ 400) {
+ Reflector::clearCache();
+}
+```
+
+## Next steps
+
+- ๐ [Installation guide](installation.md)
+- ๐งช [Testing guide](testing.md)
+- ๐ ๏ธ [Development guide](development.md)
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..8561bd6
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,40 @@
+# Installation guide
+
+## System requirements
+
+- [`PHP`](https://www.php.net/downloads) 8.1 or higher.
+- [`Composer`](https://getcomposer.org/download/) for dependency management.
+
+## Installation
+
+### Method 1: Using [Composer](https://getcomposer.org/download/) (recommended)
+
+Install the package.
+
+```bash
+composer require php-forge/helper:^0.2
+```
+
+### Method 2: Manual installation
+
+Add to your `composer.json`.
+
+```json
+{
+ "require": {
+ "php-forge/helper": "^0.2"
+ }
+}
+```
+
+Then run.
+
+```bash
+composer update
+```
+
+## Next steps
+
+- ๐ก [Usage examples](examples.md)
+- ๐งช [Testing guide](testing.md)
+- ๐ ๏ธ [Development guide](development.md)
diff --git a/docs/svgs/features-mobile.svg b/docs/svgs/features-mobile.svg
index 3bc6ccf..185274f 100644
--- a/docs/svgs/features-mobile.svg
+++ b/docs/svgs/features-mobile.svg
@@ -1,4 +1,4 @@
-
+
+
+
Bounded Metadata Cache
+
Reuse reflection metadata with bounded FIFO eviction and explicit cache lifecycle control for long-running workers.
+
Password Generation
Generate secure passwords with lowercase, uppercase, digits, and special characters.
+
+
Reflection Metadata
+
Inspect class short names, properties, attributes, and property types including union, intersection, and DNF declarations.
+
Predictable Output
Helpers are deterministic in shape and formatting, making them safe for APIs, forms, and data pipelines.
diff --git a/docs/svgs/features.svg b/docs/svgs/features.svg
index ff6f26a..ae75e5c 100644
--- a/docs/svgs/features.svg
+++ b/docs/svgs/features.svg
@@ -1,4 +1,4 @@
-
+
+
+
Bounded Metadata Cache
+
Reuse reflection metadata with bounded FIFO eviction and explicit cache lifecycle control for long-running workers.
+
Password Generation
Generate secure passwords with lowercase, uppercase, digits, and special characters.
+
+
Reflection Metadata
+
Inspect class short names, properties, attributes, and property types including union, intersection, and DNF declarations.
+
Predictable Output
Helpers are deterministic in shape and formatting, making them safe for APIs, forms, and data pipelines.
diff --git a/src/Exception/Message.php b/src/Exception/Message.php
index 87e809f..f3c40df 100644
--- a/src/Exception/Message.php
+++ b/src/Exception/Message.php
@@ -23,6 +23,20 @@ enum Message: string
*/
case PASSWORD_LENGTH_TOO_SHORT = "Password length must be at least '%d' characters.";
+ /**
+ * Error message for missing reflected property.
+ *
+ * Format: "Property '%s' does not exist in '%s'."
+ */
+ case REFLECTOR_PROPERTY_NOT_FOUND = "Property '%s' does not exist in '%s'.";
+
+ /**
+ * Error message for invalid reflection target.
+ *
+ * Format: "Invalid reflection target '%s'."
+ */
+ case REFLECTOR_TARGET_INVALID = "Invalid reflection target '%s'.";
+
/**
* Returns the formatted message string for the error case.
*
diff --git a/src/Reflector.php b/src/Reflector.php
new file mode 100644
index 0000000..c05e4ed
--- /dev/null
+++ b/src/Reflector.php
@@ -0,0 +1,389 @@
+>
+ */
+ private static array $reflectionClassCache = [];
+
+ /**
+ * Returns the current number of cached reflection classes.
+ *
+ * Usage example:
+ * ```php
+ * $cacheSize = \PHPForge\Helper\Reflector::cacheSize();
+ * ```
+ *
+ * @return int Number of cached reflection classes.
+ */
+ public static function cacheSize(): int
+ {
+ return count(self::$reflectionClassCache);
+ }
+
+ /**
+ * Returns class attributes, optionally filtered by attribute class.
+ *
+ * Usage example:
+ * ```php
+ * $attributes = \PHPForge\Helper\Reflector::classAttributes(
+ * SomeClass::class,
+ * SomeAttribute::class,
+ * ReflectionAttribute::IS_INSTANCEOF,
+ * );
+ * ```
+ *
+ * @return array Class attributes.
+ *
+ * @phpstan-return list
>
+ */
+ public static function classAttributes(object|string $class, string|null $attribute = null, int $flags = 0): array
+ {
+ $reflectionClass = self::reflectionClass($class);
+
+ if ($attribute === null) {
+ return $reflectionClass->getAttributes();
+ }
+
+ return $reflectionClass->getAttributes($attribute, $flags);
+ }
+
+ /**
+ * Clears the internal reflection class cache.
+ *
+ * Usage example:
+ * ```php
+ * \PHPForge\Helper\Reflector::clearCache();
+ * ```
+ */
+ public static function clearCache(): void
+ {
+ self::$reflectionClassCache = [];
+ }
+
+ /**
+ * Returns the first matching instantiated property attribute.
+ *
+ * Usage example:
+ * ```php
+ * $attributeInstance = \PHPForge\Helper\Reflector::firstPropertyAttribute(
+ * SomeClass::class,
+ * 'someProperty',
+ * SomeAttribute::class,
+ * ReflectionAttribute::IS_INSTANCEOF,
+ * );
+ * ```
+ *
+ * @return object|null First matching instantiated attribute, or `null` if no matching attribute is found.
+ */
+ public static function firstPropertyAttribute(
+ object|string $class,
+ string $property,
+ string $attribute,
+ int $flags = 0,
+ ): object|null {
+ $attributes = self::propertyAttributes($class, $property, $attribute, $flags);
+
+ if ($attributes === []) {
+ return null;
+ }
+
+ return $attributes[0]->newInstance();
+ }
+
+ /**
+ * Checks whether a property exists on the class.
+ *
+ * Usage example:
+ * ```php
+ * $hasProperty = \PHPForge\Helper\Reflector::hasProperty(SomeClass::class, 'someProperty');
+ * ```
+ *
+ * @return bool `true` if the property exists, `false` otherwise.
+ */
+ public static function hasProperty(object|string $class, string $property): bool
+ {
+ return self::reflectionClass($class)->hasProperty($property);
+ }
+
+ /**
+ * Returns reflected properties, optionally filtered by visibility flags.
+ *
+ * Usage example:
+ * ```php
+ * $publicProperties = \PHPForge\Helper\Reflector::properties(SomeClass::class, ReflectionProperty::IS_PUBLIC);
+ * ```
+ *
+ * @return array Reflected properties.
+ *
+ * @phpstan-return list
+ */
+ public static function properties(object|string $class, int|null $filter = null): array
+ {
+ $reflectionClass = self::reflectionClass($class);
+
+ if ($filter !== null) {
+ return $reflectionClass->getProperties($filter);
+ }
+
+ return $reflectionClass->getProperties();
+ }
+
+ /**
+ * Returns a reflected property.
+ *
+ * Usage example:
+ * ```php
+ * $property = \PHPForge\Helper\Reflector::property(SomeClass::class, 'someProperty');
+ * ```
+ *
+ * @throws InvalidArgumentException if the property does not exist.
+ *
+ * @return ReflectionProperty Reflected property.
+ */
+ public static function property(object|string $class, string $property): ReflectionProperty
+ {
+ $reflectionClass = self::reflectionClass($class);
+
+ if (!$reflectionClass->hasProperty($property)) {
+ throw new InvalidArgumentException(
+ Message::REFLECTOR_PROPERTY_NOT_FOUND->getMessage($property, $reflectionClass->getName()),
+ );
+ }
+
+ return $reflectionClass->getProperty($property);
+ }
+
+ /**
+ * Returns instantiated property attributes.
+ *
+ * Usage example:
+ * ```php
+ * $attributeInstances = \PHPForge\Helper\Reflector::propertyAttributeInstances(
+ * SomeClass::class,
+ * 'someProperty',
+ * SomeAttribute::class,
+ * ReflectionAttribute::IS_INSTANCEOF,
+ * );
+ * ```
+ *
+ * @return array Instantiated property attributes.
+ *
+ * @phpstan-return list
+ */
+ public static function propertyAttributeInstances(
+ object|string $class,
+ string $property,
+ string|null $attribute = null,
+ int $flags = 0,
+ ): array {
+ $attributes = self::propertyAttributes($class, $property, $attribute, $flags);
+
+ return array_map(
+ static fn(ReflectionAttribute $reflectionAttribute): object => $reflectionAttribute->newInstance(),
+ $attributes,
+ );
+ }
+
+ /**
+ * Returns property attributes, optionally filtered by attribute class.
+ *
+ * Usage example:
+ * ```php
+ * $attributes = \PHPForge\Helper\Reflector::propertyAttributes(
+ * SomeClass::class,
+ * 'someProperty',
+ * SomeAttribute::class,
+ * ReflectionAttribute::IS_INSTANCEOF,
+ * );
+ * ```
+ *
+ * @return array Property attributes.
+ *
+ * @phpstan-return list>
+ */
+ public static function propertyAttributes(
+ object|string $class,
+ string $property,
+ string|null $attribute = null,
+ int $flags = 0,
+ ): array {
+ $reflectionProperty = self::property($class, $property);
+
+ if ($attribute === null) {
+ return $reflectionProperty->getAttributes();
+ }
+
+ return $reflectionProperty->getAttributes($attribute, $flags);
+ }
+
+ /**
+ * Returns property type names.
+ *
+ * For nullable named types, the list includes `'null'` as an extra element.
+ *
+ * Usage example:
+ * ```php
+ * $typeNames = \PHPForge\Helper\Reflector::propertyTypeNames(SomeClass::class, 'someProperty');
+ * ```
+ *
+ * @return list Type names of the property, or an empty list if the property has no type declaration.
+ *
+ * @phpstan-return list
+ */
+ public static function propertyTypeNames(object|string $class, string $property): array
+ {
+ $type = self::property($class, $property)->getType();
+
+ if ($type instanceof ReflectionNamedType) {
+ $typeName = $type->getName();
+
+ if ($type->allowsNull() && $typeName !== 'null' && $typeName !== 'mixed') {
+ return [$typeName, 'null'];
+ }
+
+ return [$typeName];
+ }
+
+ if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
+ return self::collectNestedTypeNames($type);
+ }
+
+ return [];
+ }
+
+ /**
+ * Returns the short class name.
+ *
+ * Usage example:
+ * ```php
+ * $shortName = \PHPForge\Helper\Reflector::shortName(SomeClass::class);
+ * ```
+ *
+ * @return string Short name of the class, or an empty string for anonymous classes.
+ */
+ public static function shortName(object|string $class): string
+ {
+ $reflectionClass = self::reflectionClass($class);
+
+ return $reflectionClass->isAnonymous() ? '' : $reflectionClass->getShortName();
+ }
+
+ /**
+ * Collects all named type names from nested union/intersection type structures.
+ *
+ * @return array Type names collected from the nested type structure.
+ *
+ * @phpstan-return list
+ */
+ private static function collectNestedTypeNames(ReflectionIntersectionType|ReflectionUnionType $type): array
+ {
+ $typeNames = [];
+
+ $queue = $type->getTypes();
+
+ while ($queue !== []) {
+ $nestedType = array_shift($queue);
+
+ if ($nestedType instanceof ReflectionNamedType) {
+ $typeNames[] = $nestedType->getName();
+
+ continue;
+ }
+
+ if ($nestedType instanceof ReflectionUnionType || $nestedType instanceof ReflectionIntersectionType) {
+ foreach ($nestedType->getTypes() as $innerType) {
+ $queue[] = $innerType;
+ }
+ }
+ }
+
+ return $typeNames;
+ }
+
+ /**
+ * Creates a reflection class from an object or class name.
+ *
+ * @param object|string $class Object instance or class name to reflect.
+ *
+ * @throws InvalidArgumentException if the class target is invalid.
+ *
+ * @return ReflectionClass Reflection class instance.
+ */
+ private static function reflectionClass(object|string $class): ReflectionClass
+ {
+ $className = is_object($class) ? $class::class : $class;
+
+ if (
+ !class_exists($className)
+ && !interface_exists($className)
+ && !trait_exists($className)
+ && !enum_exists($className)
+ ) {
+ throw new InvalidArgumentException(
+ Message::REFLECTOR_TARGET_INVALID->getMessage($className),
+ );
+ }
+
+ if (isset(self::$reflectionClassCache[$className])) {
+ return self::$reflectionClassCache[$className];
+ }
+
+ $reflectionClass = new ReflectionClass($className);
+
+ if (!$reflectionClass->isAnonymous()) {
+ if (count(self::$reflectionClassCache) >= self::MAX_REFLECTION_CLASS_CACHE_SIZE) {
+ array_shift(self::$reflectionClassCache);
+ }
+
+ self::$reflectionClassCache[$className] = $reflectionClass;
+ }
+
+ return $reflectionClass;
+ }
+}
diff --git a/tests/ReflectorTest.php b/tests/ReflectorTest.php
new file mode 100644
index 0000000..f150288
--- /dev/null
+++ b/tests/ReflectorTest.php
@@ -0,0 +1,509 @@
+getName(),
+ 'Should return the expected attribute class name.',
+ );
+ }
+
+ public function testClassAttributesWithFilterReturnsMatchingAttributesOnly(): void
+ {
+ $attributes = Reflector::classAttributes(ReflectionFixture::class, Label::class);
+
+ self::assertCount(
+ 1,
+ $attributes,
+ 'Should return only matching class attributes for the requested filter.',
+ );
+ }
+
+ public function testClearCacheEmptiesReflectionClassCache(): void
+ {
+ Reflector::clearCache();
+ Reflector::shortName(ReflectionFixture::class);
+
+ self::assertGreaterThan(
+ 0,
+ Reflector::cacheSize(),
+ 'Should populate cache after reflection usage.',
+ );
+
+ Reflector::clearCache();
+
+ self::assertSame(
+ 0,
+ Reflector::cacheSize(),
+ 'Should clear all cached reflection classes.',
+ );
+ }
+
+ public function testFirstPropertyAttributeReturnsFirstMatchingAttributeInstance(): void
+ {
+ $attribute = Reflector::firstPropertyAttribute(ReflectionFixture::class, 'name', Label::class);
+
+ if (!$attribute instanceof Label) {
+ self::fail('Should return an instantiated attribute object when the attribute exists.');
+ }
+
+ self::assertSame(
+ 'primary',
+ $attribute->value,
+ 'Should return the first declared repeatable attribute instance.',
+ );
+ }
+
+ public function testFirstPropertyAttributeReturnsNullWhenAttributeDoesNotExist(): void
+ {
+ self::assertNull(
+ Reflector::firstPropertyAttribute(ReflectionFixture::class, 'name', self::class),
+ "Should return 'null' when the requested attribute is not present.",
+ );
+ }
+
+ public function testHasPropertyReturnsExpectedResult(): void
+ {
+ self::assertTrue(
+ Reflector::hasProperty(ReflectionFixture::class, 'name'),
+ "Should return 'true' for existing properties.",
+ );
+ self::assertFalse(
+ Reflector::hasProperty(ReflectionFixture::class, 'missing'),
+ "Should return 'false' for missing properties.",
+ );
+ }
+
+ public function testPropertiesReturnsAllPropertiesWhenFilterIsNull(): void
+ {
+ $allProperties = Reflector::properties(ReflectionFixture::class);
+ $allPropertyNames = [];
+
+ foreach ($allProperties as $property) {
+ $allPropertyNames[] = $property->getName();
+ }
+
+ self::assertContains(
+ 'name',
+ $allPropertyNames,
+ 'Should include public properties when no filter is provided.',
+ );
+ self::assertContains(
+ 'hidden',
+ $allPropertyNames,
+ 'Should include private properties when no filter is provided.',
+ );
+ }
+
+ public function testPropertiesReturnsFilteredListWhenVisibilityFilterIsProvided(): void
+ {
+ $publicProperties = Reflector::properties(ReflectionFixture::class, ReflectionProperty::IS_PUBLIC);
+
+ $publicPropertyNames = [];
+
+ foreach ($publicProperties as $publicProperty) {
+ $publicPropertyNames[] = $publicProperty->getName();
+ }
+
+ self::assertContains(
+ 'name',
+ $publicPropertyNames,
+ 'Should include public properties when a public filter is used.',
+ );
+ self::assertNotContains(
+ 'hidden',
+ $publicPropertyNames,
+ 'Should exclude private properties when a public filter is used.',
+ );
+ }
+
+ public function testPropertyAttributeInstancesReturnsInstantiatedAttributes(): void
+ {
+ $instances = Reflector::propertyAttributeInstances(ReflectionFixture::class, 'name', Label::class);
+
+ self::assertCount(
+ 2,
+ $instances,
+ 'Should instantiate all matching repeatable attributes.',
+ );
+
+ foreach ($instances as $instance) {
+ self::assertInstanceOf(Label::class, $instance);
+ }
+
+ /** @phpstan-var list $instances */
+ $instanceValues = [];
+
+ foreach ($instances as $instance) {
+ $instanceValues[] = $instance->value;
+ }
+
+ self::assertSame(
+ [
+ 'primary',
+ 'secondary',
+ ],
+ $instanceValues,
+ 'Should preserve declaration order for instantiated repeatable attributes.',
+ );
+ }
+
+ public function testPropertyAttributesReturnsPropertyAttributes(): void
+ {
+ $attributes = Reflector::propertyAttributes(ReflectionFixture::class, 'name');
+
+ self::assertCount(
+ 3,
+ $attributes,
+ 'Should return every attribute declared on the property.',
+ );
+ }
+
+ public function testPropertyAttributesWithFilterReturnsMatchingAttributesOnly(): void
+ {
+ $attributes = Reflector::propertyAttributes(ReflectionFixture::class, 'name', Marker::class);
+
+ self::assertCount(
+ 1,
+ $attributes,
+ 'Should return only matching attributes when a property filter is passed.',
+ );
+
+ $firstAttribute = null;
+
+ foreach ($attributes as $attribute) {
+ $firstAttribute = $attribute;
+ break;
+ }
+
+ self::assertNotNull($firstAttribute);
+ self::assertSame(
+ Marker::class,
+ $firstAttribute->getName(),
+ 'Should return the expected attribute class for filtered lookup.',
+ );
+ }
+
+ public function testPropertyReturnsReflectionProperty(): void
+ {
+ self::assertInstanceOf(
+ ReflectionProperty::class,
+ Reflector::property(ReflectionFixture::class, 'name'),
+ 'Should return a reflection property instance for existing properties.',
+ );
+ }
+
+ public function testPropertyThrowsInvalidArgumentExceptionWhenPropertyDoesNotExist(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ Message::REFLECTOR_PROPERTY_NOT_FOUND->getMessage('missing', ReflectionFixture::class),
+ );
+
+ Reflector::property(ReflectionFixture::class, 'missing');
+ }
+
+ public function testPropertyTypeNamesReturnsEmptyArrayForUntypedProperty(): void
+ {
+ self::assertSame(
+ [],
+ Reflector::propertyTypeNames(ReflectionFixture::class, 'untyped'),
+ 'Should return an empty list for untyped properties.',
+ );
+ }
+
+ public function testPropertyTypeNamesReturnsExpectedNamesForDnfProperty(): void
+ {
+ if (PHP_VERSION_ID < 80200) {
+ self::markTestSkipped('DNF types require PHP 8.2 or later.');
+ }
+
+ $className = 'PHPForge\\Helper\\Tests\\Support\\Model\\DnfFixture';
+
+ if (!class_exists($className)) {
+ eval(
+ <<<'PHP'
+namespace PHPForge\Helper\Tests\Support\Model;
+
+use PHPForge\Helper\Tests\Support\Contract\LeftContract;
+use PHPForge\Helper\Tests\Support\Contract\RightContract;
+use PHPForge\Helper\Tests\Support\Contract\Status;
+
+final class DnfFixture
+{
+ public (LeftContract&RightContract)|Status $value;
+}
+PHP
+ );
+ }
+
+ self::assertSame(
+ [
+ Status::class,
+ LeftContract::class,
+ RightContract::class,
+ ],
+ Reflector::propertyTypeNames($className, 'value'),
+ 'Should include all named members from DNF type declarations.',
+ );
+ }
+
+ public function testPropertyTypeNamesReturnsExpectedNamesForIntersectionProperty(): void
+ {
+ self::assertSame(
+ [
+ 'PHPForge\\Helper\\Tests\\Support\\Contract\\LeftContract',
+ 'PHPForge\\Helper\\Tests\\Support\\Contract\\RightContract',
+ ],
+ Reflector::propertyTypeNames(ReflectionFixture::class, 'intersection'),
+ 'Should include all named intersection members.',
+ );
+ }
+
+ public function testPropertyTypeNamesReturnsExpectedNamesForNamedProperty(): void
+ {
+ self::assertSame(
+ ['int'],
+ Reflector::propertyTypeNames(ReflectionFixture::class, 'id'),
+ 'Should return one type name for named property types.',
+ );
+ }
+
+ public function testPropertyTypeNamesReturnsExpectedNamesForNullableProperty(): void
+ {
+ self::assertSame(
+ ['string', 'null'],
+ Reflector::propertyTypeNames(ReflectionFixture::class, 'nullable'),
+ "Should append 'null' for nullable named property types.",
+ );
+ }
+
+ public function testPropertyTypeNamesReturnsExpectedNamesForUnionProperty(): void
+ {
+ self::assertSame(
+ ['string', 'int'],
+ Reflector::propertyTypeNames(ReflectionFixture::class, 'union'),
+ 'Should include all named union members.',
+ );
+ }
+
+ public function testPropertyTypeNamesReturnsMixedWithoutExplicitNull(): void
+ {
+ self::assertSame(
+ ['mixed'],
+ Reflector::propertyTypeNames(ReflectionFixture::class, 'payload'),
+ "Should return only 'mixed' without appending an explicit null type.",
+ );
+ }
+
+ public function testReflectionClassCacheEvictsOldestEntryWhenCacheSizeLimitIsReached(): void
+ {
+ $reflectorClass = new ReflectionClass(Reflector::class);
+
+ $cacheProperty = $reflectorClass->getProperty('reflectionClassCache');
+ /** @phpstan-var int $cacheLimit */
+ $cacheLimit = $reflectorClass->getConstant('MAX_REFLECTION_CLASS_CACHE_SIZE');
+
+ $filledCache = [];
+ $reflection = new ReflectionClass(stdClass::class);
+
+ for ($index = 0; $index < $cacheLimit; $index++) {
+ $filledCache['cached-' . $index] = $reflection;
+ }
+
+ $cacheProperty->setValue(null, $filledCache);
+
+ Reflector::shortName(ReflectionFixture::class);
+
+ /** @phpstan-var array> $cacheAfterInsert */
+ $cacheAfterInsert = $cacheProperty->getValue();
+
+ self::assertCount(
+ $cacheLimit,
+ $cacheAfterInsert,
+ 'Should keep cache size at the configured limit after inserting a new class.',
+ );
+ self::assertArrayNotHasKey(
+ 'cached-0',
+ $cacheAfterInsert,
+ 'Should evict the oldest cached entry when cache size limit is reached.',
+ );
+ self::assertArrayHasKey(
+ ReflectionFixture::class,
+ $cacheAfterInsert,
+ 'Should cache the newly reflected class after evicting the oldest entry.',
+ );
+ }
+
+ public function testReflectionClassIsCachedForRepeatedLookups(): void
+ {
+ $cacheProperty = (new ReflectionClass(Reflector::class))->getProperty('reflectionClassCache');
+
+ $cacheProperty->setValue(null, []);
+
+ Reflector::shortName(ReflectionFixture::class);
+
+ /** @var array> $firstCache */
+ $firstCache = $cacheProperty->getValue();
+
+ self::assertArrayHasKey(
+ ReflectionFixture::class,
+ $firstCache,
+ 'Should cache reflection class instances after first lookup.',
+ );
+
+ $firstInstance = $firstCache[ReflectionFixture::class] ?? null;
+
+ Reflector::hasProperty(ReflectionFixture::class, 'name');
+
+ /** @phpstan-var array> $secondCache */
+ $secondCache = $cacheProperty->getValue();
+
+ $secondInstance = $secondCache[ReflectionFixture::class] ?? null;
+
+ self::assertCount(
+ 1,
+ $secondCache,
+ 'Should keep a single cached entry for repeated lookups of the same class.',
+ );
+ self::assertInstanceOf(
+ ReflectionClass::class,
+ $firstInstance,
+ 'Should keep a reflection class instance in cache.',
+ );
+ self::assertSame(
+ $firstInstance,
+ $secondInstance,
+ 'Should reuse the same reflection class instance between calls.',
+ );
+ }
+
+ public function testShortNameReturnsEmptyStringForAnonymousClass(): void
+ {
+ self::assertSame(
+ '',
+ Reflector::shortName(new class {}),
+ 'Should return an empty short name for anonymous classes.',
+ );
+ }
+
+ public function testShortNameReturnsShortNameForEnumTarget(): void
+ {
+ self::assertSame(
+ 'Status',
+ Reflector::shortName(Status::class),
+ 'Should return the short name for enum targets.',
+ );
+ }
+
+ public function testShortNameReturnsShortNameForInterfaceTarget(): void
+ {
+ self::assertSame(
+ 'LeftContract',
+ Reflector::shortName(LeftContract::class),
+ 'Should return the short name for interface targets.',
+ );
+ }
+
+ public function testShortNameReturnsShortNameForNamedClass(): void
+ {
+ self::assertSame(
+ 'ReflectionFixture',
+ Reflector::shortName(ReflectionFixture::class),
+ 'Should return the short name for named classes.',
+ );
+ }
+
+ public function testShortNameReturnsShortNameForTraitTarget(): void
+ {
+ self::assertSame(
+ 'UsesTimestamp',
+ Reflector::shortName(UsesTimestamp::class),
+ 'Should return the short name for trait targets.',
+ );
+ }
+
+ public function testThrowsInvalidArgumentExceptionForInvalidReflectionTarget(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ Message::REFLECTOR_TARGET_INVALID->getMessage('missing\\class\\Name'),
+ );
+
+ Reflector::shortName('missing\\class\\Name');
+ }
+}
diff --git a/tests/Support/Attribute/Label.php b/tests/Support/Attribute/Label.php
new file mode 100644
index 0000000..221f899
--- /dev/null
+++ b/tests/Support/Attribute/Label.php
@@ -0,0 +1,19 @@
+intersection = new BothContract();
+ }
+
+ public function hidden(): string
+ {
+ return $this->hidden;
+ }
+}
diff --git a/tests/Support/Model/TraitConsumer.php b/tests/Support/Model/TraitConsumer.php
new file mode 100644
index 0000000..1b8a580
--- /dev/null
+++ b/tests/Support/Model/TraitConsumer.php
@@ -0,0 +1,18 @@
+