diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md index 81b66e8..73c753a 100644 --- a/docs/supported-types/primitives-and-scalars.md +++ b/docs/supported-types/primitives-and-scalars.md @@ -74,6 +74,63 @@ To prevent rounding artifacts from causing unexpected type failures, TypePHP eva --- +## Wildcard Constant Patterns (`Class::PREFIX_*` & `StatusEnum::*`) + +TypePHP supports validating parameters, return types, properties, and `@var` local assignments against wildcard constant patterns (`Class::PREFIX_*` or `StatusEnum::*`). + +* **Class Constants (`Class::PREFIX_*`):** TypePHP uses Reflection to safely inspect all matching constants—including `public`, `protected`, and `private` class constants—and validates that the incoming value matches one of the declared constant scalar values. +* **Enums (`StatusEnum::*` or `StatusEnum::ACT*`):** Matches incoming **Enum case objects** against the wildcard case pattern. + +```php +class MigrationCollectionLoader +{ + public const VERSION_SELECTION_ALL = 'all'; + public const VERSION_SELECTION_BLUE_GREEN = 'blue-green'; + private const VERSION_SELECTION_INTERNAL = 'internal-mode'; + + /** + * @param self::VERSION_SELECTION_* $mode + */ + public function collectAllForVersion(string $mode): string + { + return $mode; + } +} + +enum StatusEnum: string +{ + case Active = 'active'; + case Inactive = 'inactive'; + case Pending = 'pending'; +} + +class EnumProcessor +{ + /** + * @param StatusEnum::ACT* $status + */ + public static function processActive(StatusEnum $status): StatusEnum + { + return $status; + } +} + +$loader = new MigrationCollectionLoader(); + +// Valid Class Constant Calls (Matches public and private VERSION_SELECTION_* constant values) +$loader->collectAllForVersion('all'); +$loader->collectAllForVersion('internal-mode'); + +// Valid Enum Wildcard Call (Matches StatusEnum::ACT* -> StatusEnum::Active) +EnumProcessor::processActive(StatusEnum::Active); + +// Invalid Enum Wildcard Call (StatusEnum::Pending does not match StatusEnum::ACT*) +EnumProcessor::processActive(StatusEnum::Pending); +// Throws: TypeError: Argument $status must be a valid constant matching StatusEnum::ACT* +``` + +--- + ## Integer Refinements and Ranges TypePHP enforces exact value constraints and bounds on integer parameters: @@ -358,4 +415,4 @@ function badHaltExecution(): string badHaltExecution(); // Throws: TypeError: badHaltExecution(): Return value must be of type never -``` +``` \ No newline at end of file diff --git a/src/Validator/ArrayShapeValidator.php b/src/Validator/ArrayShapeValidator.php index 95f379a..db82e0b 100644 --- a/src/Validator/ArrayShapeValidator.php +++ b/src/Validator/ArrayShapeValidator.php @@ -91,4 +91,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} \ No newline at end of file +} diff --git a/src/Validator/ConstValidator.php b/src/Validator/ConstValidator.php index a794916..b5c3506 100644 --- a/src/Validator/ConstValidator.php +++ b/src/Validator/ConstValidator.php @@ -18,10 +18,15 @@ use TypePHP\Internal\TypeFormatter; /** - * @internal Validates literal values, class constants, and PHP 8.1 Enum cases against ConstTypeNode ASTs. + * @internal Validates literal values, class constants, and wildcard constant patterns (Class::PREFIX_*) against ConstTypeNode ASTs. */ final class ConstValidator implements TypeValidatorInterface { + /** + * @var array> + */ + private static array $wildcardConstantCache = []; + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { /** @var ConstTypeNode $constTypeNode */ @@ -41,9 +46,24 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } elseif ($constExpr instanceof ConstExprFloatNode) { $expected = (float) $constExpr->value; } elseif ($constExpr instanceof ConstFetchNode) { - $fqcnConstant = $constExpr->className !== '' - ? $constExpr->className . '::' . $constExpr->name - : $constExpr->name; + $className = $constExpr->className; + $pattern = $constExpr->name; + + if (str_contains($pattern, '*')) { + $allowedValues = self::resolveWildcardConstantValues($className, $pattern); + + if (! \in_array($value, $allowedValues, true)) { + $fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern; + + return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; + } + + $fqcnConstant = $className !== '' + ? $className . '::' . $pattern + : $pattern; if (\defined($fqcnConstant)) { $expected = \constant($fqcnConstant); @@ -69,4 +89,36 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } + + /** + * Resolves and caches all values of class constants matching a wildcard pattern (e.g. PREFIX_*). + * + * @return array + */ + private static function resolveWildcardConstantValues(string $className, string $pattern): array + { + $cacheKey = $className . '::' . $pattern; + if (isset(self::$wildcardConstantCache[$cacheKey])) { + return self::$wildcardConstantCache[$cacheKey]; + } + + $values = []; + + if ($className !== '' && (class_exists($className) || interface_exists($className))) { + try { + $refClass = new \ReflectionClass($className); + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; + + foreach ($refClass->getConstants() as $cName => $cValue) { + if (preg_match($regex, $cName) === 1) { + $values[] = $cValue; + } + } + } catch (\ReflectionException $e) { + // Silently ignore reflection errors + } + } + + return self::$wildcardConstantCache[$cacheKey] = $values; + } } diff --git a/tests/Fixtures/Types/EnumWildcard.php b/tests/Fixtures/Types/EnumWildcard.php new file mode 100644 index 0000000..265b219 --- /dev/null +++ b/tests/Fixtures/Types/EnumWildcard.php @@ -0,0 +1,24 @@ +, list} * @phpstan-type MixedTupleShape array{non-empty-string, code: positive-int, list} + * * @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple * * @param LocalTupleAlias $payload @@ -153,7 +154,6 @@ function testReturnKeylessTuple(bool $valid): array return [[10, 20], 'bundle']; } - describe('Class Object Arrays (Dog[])', function () { test('accepts array of matching class instances', function () { expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2); @@ -379,4 +379,4 @@ function testReturnKeylessTuple(bool $valid): array ->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int") ; }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php index de3fa58..98635b4 100644 --- a/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php +++ b/tests/TypeChecking/ArraysAndShapes/UnionErrorBubblingTest.php @@ -140,6 +140,7 @@ public function __construct() test('falls back gracefully to generic union error when no deep branch matches structure', function () { expect(fn () => testDeepUnionError('string_instead_of_array')) - ->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)'); + ->toThrow(TypeError::class, 'must be of type (array{id: int, tags: list<(string | int)>} | null)') + ; }); }); diff --git a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php index 16d64c5..449518c 100644 --- a/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php +++ b/tests/TypeChecking/Generics/GenericTemplateBoundsStressTest.php @@ -226,6 +226,7 @@ function testInferredOverridesDefault(mixed $input, mixed $valueToReturn): mixed expect(testInferredOverridesDefault($dt, $dt))->toBe($dt); expect(fn () => testInferredOverridesDefault($dt, new stdClass())) - ->toThrow(TypeError::class, 'Return value'); + ->toThrow(TypeError::class, 'Return value') + ; }); }); diff --git a/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php b/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php index fc3f622..f2e4776 100644 --- a/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php +++ b/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +use TypePHP\Tests\Fixtures\Types\EnumWildcard; +use TypePHP\Tests\Fixtures\Types\StatusEnum; +use TypePHP\Tests\Fixtures\Types\WildcardConstantFixture; + /** * 1. Integer Extended Types * @@ -140,3 +144,32 @@ function testLiteralUnionsParam(string $status, int $code): bool ; }); }); + +describe('Wildcard Constant Pattern Validation (self::PREFIX_*)', function () { + test('accepts values matching wildcard constant patterns (including private constants)', function () { + expect(WildcardConstantFixture::setVersionMode('all'))->toBe('all'); + expect(WildcardConstantFixture::setVersionMode('blue-green'))->toBe('blue-green'); + expect(WildcardConstantFixture::setVersionMode('internal-mode'))->toBe('internal-mode'); + }); + + test('throws TypeError on value not matching wildcard constant pattern', function () { + expect(fn () => WildcardConstantFixture::setVersionMode('invalid_mode')) + ->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\WildcardConstantFixture::VERSION_SELECTION_*') + ; + }); +}); + +describe('Enum Wildcard Pattern Validation (StatusEnum::*)', function () { + test('accepts Enum case objects matching wildcard patterns (StatusEnum::* & StatusEnum::ACT*)', function () { + expect(EnumWildcard::processEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active); + expect(EnumWildcard::processEnumCase(StatusEnum::Pending))->toBe(StatusEnum::Pending); + + expect(EnumWildcard::processPrefixEnumCase(StatusEnum::Active))->toBe(StatusEnum::Active); + }); + + test('throws TypeError when Enum case object does not match prefix wildcard (e.g. Pending for StatusEnum::ACT*)', function () { + expect(fn () => EnumWildcard::processPrefixEnumCase(StatusEnum::Pending)) + ->toThrow(TypeError::class, 'must be a valid constant matching TypePHP\\Tests\\Fixtures\\Types\\StatusEnum::ACT*') + ; + }); +});