diff --git a/README.md b/README.md index 3b0cb52..2052ed8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@

TypePHP

+

+ No transpilation. No build steps. No C-extensions.
+ Drop TypePHP into your existing codebase and let your DocBlocks scream when types fail.
+

+

Build Status Latest Stable Version @@ -11,7 +16,7 @@ ------ -TypePHP is the first pure-PHP library that transparently enforces extended PHPDoc type contracts (generics, array shapes, scalar refinements, and callables) at runtime during execution, without introducing any new syntax or requiring C-extensions. +TypePHP is a transparent, pure-PHP runtime type checker. You don't have to refactor a single line of your codebase, setup complex build toolchains, or compile C-extensions and simply run your existing code, and TypePHP will enforce your extended PHPDoc contracts (generics, array shapes, `key-of`/`value-of` extractions, and scalar refinements) dynamically at runtime. **[Read the full TypePHP documentation »](https://typephp-php.github.io/typephp/)** diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7dc502b..68bc05b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -9,8 +9,9 @@ export default defineConfig({ nav: [ { text: 'Home', link: '/' }, { text: 'Documentation', link: '/getting-started/installation' }, - { text: 'Architecture', link: '/architecture/how-it-works' }, - { text: 'CLI', link: '/production/cache-commands' }, + { text: 'Generics', link: '/generics/generics-and-bounds' }, + { text: 'CLI', link: '/getting-started/cli-commands' }, + { text: 'FAQ', link: '/troubleshooting' }, { text: 'GitHub', link: 'https://github.com/typephp-php/typephp' } ], sidebar: [ @@ -20,52 +21,57 @@ export default defineConfig({ { text: 'Installation', link: '/getting-started/installation' }, { text: 'Quick Start', link: '/getting-started/quick-start' }, { text: 'Configuration', link: '/getting-started/configuration' }, + { text: 'CLI Commands', link: '/getting-started/cli-commands' }, ] }, { - text: 'Architecture', - items: [ - { text: 'How It Works', link: '/architecture/how-it-works' }, - ] - }, - { - text: 'Core Concepts', + text: 'Enforcement Boundaries', items: [ { text: 'Function Contracts', link: '/core-concepts/function-contracts' }, - { text: 'Inline Variables', link: '/core-concepts/inline-variables' }, { text: 'Property Validation', link: '/core-concepts/property-validation' }, - { text: 'Generics & Bounds', link: '/core-concepts/generics-and-bounds' }, - { text: 'Type Aliases', link: '/core-concepts/type-aliases' }, + { text: 'Inline Variables', link: '/core-concepts/inline-variables' }, ] }, { - text: 'Supported Types', + text: 'Type Reference', items: [ { text: 'Primitives & Scalars', link: '/supported-types/primitives-and-scalars' }, { text: 'Arrays & Shapes', link: '/supported-types/arrays-and-shapes' }, { text: 'Callables & Closures', link: '/supported-types/callables-and-closures' }, { text: 'Iterators & Generators', link: '/supported-types/iterators-and-generators' }, { text: 'Unions, Intersections & Conditionals', link: '/supported-types/unions-intersections-and-conditionals' }, + { text: 'Type Aliases', link: '/supported-types/type-aliases' }, + ] + }, + { + text: 'Runtime Generics', + items: [ + { text: 'Generics & Bounds', link: '/generics/generics-and-bounds' }, ] }, { - text: 'Advanced Features', + text: 'Advanced & Architecture', items: [ + { text: 'How It Works', link: '/advanced/how-it-works' }, { text: 'Liskov & Inheritance', link: '/advanced/liskov-and-inheritance' }, { text: 'Vendor Isolation', link: '/advanced/vendor-and-path-filtering' }, { text: 'Ignore Annotations', link: '/advanced/ignore-annotations' }, { text: 'Extensions', link: '/advanced/extensions' }, { text: 'Exception Handling', link: '/advanced/exception-handling' }, - { text: 'Troubleshooting & FAQ', link: '/advanced/troubleshooting' } ] }, { - text: 'Production & Performance', + text: 'Production & Operations', items: [ { text: 'Production Readiness', link: '/production/production-readiness' }, - { text: 'Cache CLI Commands', link: '/production/cache-commands' }, { text: 'Performance Considerations', link: '/production/performance-considerations' }, ] + }, + { + text: 'Help & Support', + items: [ + { text: 'Troubleshooting & FAQ', link: '/troubleshooting' }, + ] } ], socialLinks: [ diff --git a/docs/architecture/how-it-works.md b/docs/advanced/how-it-works.md similarity index 100% rename from docs/architecture/how-it-works.md rename to docs/advanced/how-it-works.md diff --git a/docs/advanced/liskov-and-inheritance.md b/docs/advanced/liskov-and-inheritance.md index 3c110fc..add5fe3 100644 --- a/docs/advanced/liskov-and-inheritance.md +++ b/docs/advanced/liskov-and-inheritance.md @@ -292,35 +292,42 @@ $service->update(10, 'Charlie'); --- -## Parameter Renaming ($id $\rightarrow$ $userId$) +## Parameter Renaming ($id → $userId) & Position Shifts -PHP permits child classes to rename parameters when implementing an interface or extending a class. TypePHP maps inherited parameter contracts by **index position** (0, 1, 2...) rather than parameter name: +When a child class or attribute constructor overrides a parent method, parameter positions or parameter names may shift. TypePHP resolves parameter contract inheritance using **Name-First Resolution**: + +1. **Name Matching:** If a parameter name in the child method matches a parameter name in the parent class (e.g. `$api`), the parent's contract is inherited by that parameter regardless of its position index in the child. +2. **Position Fallback:** If a parameter is renamed in the child class (e.g., `$id` $\rightarrow$ `$userId`), TypePHP falls back to matching by position index. ```php -interface UserApiInterface +class BaseField { /** - * Interface uses parameter name $id + * Parent constructor has $api at position #1 * - * @param positive-int $id + * @param string $type + * @param bool|array{admin-api: bool} $api */ - public function find(int $id): bool; + public function __construct(string $type, bool|array $api = false) {} } -class UserApi implements UserApiInterface +class OneToManyRelation extends BaseField { - // Child renames parameter $id to $userId - public function find(int $userId): bool - { - return true; + /** + * Child inserts $entity, $ref, $onDelete BEFORE $api (position shift!) + */ + public function __construct( + string $entity, + string $ref, + OnDeleteOption $onDelete = OnDeleteOption::NO_ACTION, + bool|array $api = false + ) { + parent::__construct('one-to-many', $api); } } -$api = new UserApi(); - -// $userId = -50 is checked at index 0 against interface's @param positive-int $id! -$api->find(-50); -// Throws: TypeError: UserApi::find(): Argument $userId must be of type positive-int +// $onDelete (position #2 in child) is NOT overwritten by $api's type (position #1 in parent)! +$attr = new OneToManyRelation('unit', 'unit_id', OnDeleteOption::CASCADE, true); ``` --- diff --git a/docs/core-concepts/type-aliases.md b/docs/advanced/type-aliases.md similarity index 100% rename from docs/core-concepts/type-aliases.md rename to docs/advanced/type-aliases.md diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md index ec32f44..543da06 100644 --- a/docs/core-concepts/function-contracts.md +++ b/docs/core-concepts/function-contracts.md @@ -35,6 +35,33 @@ registerUser(-5, 'Alice', 'admin'); > **Execution Order Note:** Native PHP type hints (e.g., `int $id`, `string $username`) are evaluated by PHP's C-engine *before* function execution begins. TypePHP's extended PHPDoc contracts (e.g., `positive-int`, `non-empty-string`) execute at the very start of the function/method body. If a native type hint fails, PHP throws its native `TypeError` before TypePHP's guard rails run. +--- +## PHP 8.0+ Named Arguments + +TypePHP natively supports PHP 8.0+ Named Arguments. Because parameter contracts are mapped by parameter name rather than argument position index, you can pass named arguments in any order, and TypePHP will accurately validate each parameter: + +```php + $age + */ +function registerUser(int $id, string $username, int $age): void +{ + // ... +} + +// Valid Call: Arguments passed in completely reversed/swapped order +registerUser(age: 25, username: 'Alice', id: 42); + +// Invalid Call: $id (-5) passed as 3rd named argument +registerUser(age: 25, username: 'Alice', id: -5); +// Throws: TypeError: registerUser(): Argument $id must be of type positive-int, negative int (-5) given +``` --- ## Class Methods (Instance & Static) diff --git a/docs/core-concepts/generics-and-bounds.md b/docs/generics/generics-and-bounds.md similarity index 100% rename from docs/core-concepts/generics-and-bounds.md rename to docs/generics/generics-and-bounds.md diff --git a/docs/production/cache-commands.md b/docs/getting-started/cli-commands.md similarity index 100% rename from docs/production/cache-commands.md rename to docs/getting-started/cli-commands.md diff --git a/docs/index.md b/docs/index.md index 7a23df7..29f8b6a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: "TypePHP" text: "Transparent Runtime Type Enforcement" - tagline: "The first pure PHP library to enforce DocBlock types at runtime transparently without introducing any new syntax. Validates generics, array shapes, and advanced type contracts during execution." + tagline: "No transpilation. No build steps. No C-extensions. Just 100% pure PHP that makes your existing DocBlocks scream the moment types fail." actions: - theme: brand text: "Get Started →" @@ -16,17 +16,52 @@ hero: features: - title: "Zero Production Overhead" details: "Install as a development dependency to enforce strict types during local testing and CI/CD pipelines, guaranteeing absolute zero performance cost in live production environments." + - title: "No Transpilation or C-Extensions" + details: "Operates 100% in pure PHP user-land using native stream wrappers and AST transformations. No build scripts, Node.js tools, or C-extensions required." - title: "True Runtime Generics" details: "Binds generic template types to specific object instances dynamically using native WeakMap memory tracking." - - title: "Typed Arrays & Shapes" - details: "Deeply validates sequential lists, typed class arrays, and strict associative array shape structures right out of the box." - - title: "PHP 8.4 Support" - details: "Native support for intercepting and validating PHP 8.4 Property Hooks (get/set) and Asymmetric Visibility (public private(set))." + - title: "Arrays, Shapes & Extractions" + details: "Deeply validates sequential lists, typed arrays, array shapes, and key-of / value-of constant extractions out of the box." --- +::: tip Pure PHP • Zero Transpilation • Zero Build Steps +**You don't have to change a single line of code, and you don't need a compilation build toolchain.** TypePHP operates entirely in native PHP user-land and no custom PHP binaries, C-extensions, or Node.js transpilers needed. Drop TypePHP into your existing project, run your code, and your DocBlocks will instantly start screaming at runtime when dynamic data violates a type contract. +::: + ## See It In Action -TypePHP is the first pure PHP library that operates entirely in user-land using native stream wrappers and AST transformations. Because it requires no C-extensions or FFI, you can drop it into any PHP 8.1+ project effortlessly. It parses your standard PHPDoc annotations and enforces them the moment your code runs. +TypePHP operates entirely in user-land using native stream wrappers and AST transformations. Because it requires no C-extensions or FFI, you can drop it into any PHP 8.1+ project or web framework effortlessly. It reads your existing PHPDoc annotations and enforces them the moment your code runs. + +### Real-World Framework Guard Rails (Laravel / Symfony) +Prevent dynamic data bugs from leaking into database queries or API responses: + +```php +namespace App\Models; + +use App\Enums\Role; +use Illuminate\Database\Eloquent\Model; + +class User extends Model +{ + /** + * @return list + */ + public function assignableRoles(): array + { + if ($this->isSuperAdmin()) { + // Bug! Returns an array of Role Enum instances instead of integers: + return Role::cases(); + } + + return [Role::STAFF->value]; + } +} + +// Executing $user->assignableRoles() throws: +// TypePHP\Exception\TypeError: User::assignableRoles(): Return value[0] must be of type int, App\Enums\Role returned +``` + +--- ### True Runtime Generics Define generic templates and TypePHP will track their state in memory per object instance: @@ -51,68 +86,53 @@ $users->add(new Product('SKU-100')); // Throws TypeError: Argument $item (template T = User) must be of type User, Product given ``` -### Array Shapes & Typed Arrays -Enforce strict associative array structures and collections of specific objects: - -```php -/** - * @param array{status: 'active'|'pending', tags: list} $options - * @param User[] $collaborators - */ -function processBatch(array $options, array $collaborators): void -{ - // ... -} +--- -processBatch( - options: ['status' => 'active', 'tags' => ['php', 'types']], - collaborators: [new User(), new User()] -); // Valid +### Array Shapes & Key/Value Extractions +Enforce strict associative array structures and constant extractions: -processBatch( - options: ['status' => 'archived', 'tags' => ['php']], - collaborators: [] -); -// Throws TypeError: Argument $options['status'] must be of type ('active' | 'pending') -``` +```php +namespace App\Services; -### Scalar Refinements & Function Boundaries -Catch invalid parameters before your function executes, and invalid return values before they leak out: +use App\Database\DriverManager; -```php /** - * @param positive-int $id - * @return non-empty-string + * @phpstan-type ConnectionParams array{ + * driver: key-of, + * driverClass?: value-of + * } */ -function generateUserToken(int $id): string +class DatabaseService { - return ""; // Throws TypeError: Return value must be of type non-empty-string + /** + * @param ConnectionParams $params + */ + public function connect(array $params): void + { + // ... + } } -generateUserToken(-5); -// Throws TypeError: Argument $id must be of type positive-int, negative int (-5) given +$service = new DatabaseService(); + +$service->connect(['driver' => 'pdo_mysql']); // Valid + +$service->connect(['driver' => 'pdo_invalid']); +// Throws TypeError: Argument $params['driver'] must be a key of DriverManager::DRIVER_MAP ``` --- -## Precise Stack Trace & Error Reporting +## Precise Call-Site Trace Attribution -TypePHP injects single-line guard rails without shifting your source file line numbers. +A common problem with AST code injection is that adding new statements pushes subsequent code down, causing line numbers in stack traces to drift out of sync. -When an inline variable or type contract fails, framework error handlers and test runners (like Pest, PHPUnit, and Whoops) point **directly to the exact line number** where the invalid assignment or argument occurred in your application code: +TypePHP solves this with **Zero Line-Drift Formatting**. Injected guard rails are squashed onto single lines and appended directly to existing code blocks. **Line numbers in your source files remain 100% identical before and after transformation.** -``` - FAILED Tests\SomeTest > test - - TypeError: Variable $typeArray[3] must be of type int, string '1' given - - at tests/SomeTest.php:7 - 3| declare(strict_types=1); - 4| - 5| test('test', function () { - 6| /** @var array */ - ➜ 7| $typeArray = [1, 2, 3, '1']; - 8| - 9| expect($typeArray)->toBeArray(); - 10| }); -``` +When a type contract fails, web exception handlers (**Laravel Ignition, Whoops, Symfony ErrorHandler**) and CLI test runners (**Pest, PHPUnit**) point **directly to the exact line number** where the invalid assignment or return value occurred in your application code: + +### Web Framework Trace (Laravel Ignition) +![Laravel Ignition Exception Trace](/laravel-error-screen.png) + +### CLI Test Runner Trace (Pest PHP) +![Pest CLI Exception Trace](/pest-error-screen.png) diff --git a/docs/public/laravel-error-screen.png b/docs/public/laravel-error-screen.png new file mode 100644 index 0000000..3b54539 Binary files /dev/null and b/docs/public/laravel-error-screen.png differ diff --git a/docs/public/pest-error-screen.png b/docs/public/pest-error-screen.png new file mode 100644 index 0000000..a0de081 Binary files /dev/null and b/docs/public/pest-error-screen.png differ diff --git a/docs/supported-types/arrays-and-shapes.md b/docs/supported-types/arrays-and-shapes.md index 34d9a61..40d74ef 100644 --- a/docs/supported-types/arrays-and-shapes.md +++ b/docs/supported-types/arrays-and-shapes.md @@ -378,7 +378,50 @@ $service->configure(['driver' => 'pdo_pgsql'], 'id'); $service->configure(['driver' => 'pdo_mysql'], 'invalid'); // Throws: TypeError: Argument $shapeKey must be a key of the specified array shape ``` +--- +## Offset Access Types (`T[K]`) + +TypePHP supports evaluating offset access lookups on array shapes, constant arrays, and `@phpstan-type` aliases at runtime using `T[K]` syntax. + +> **AST Reduction:** TypePHP evaluates and reduces offset access lookups (e.g. `UserShape['id']` $\rightarrow$ `positive-int`) at the AST level before validation runs, executing type checks at **$O(1)$ constant speed**. + +```php +namespace App\Services; + +/** + * @phpstan-type UserShape array{id: positive-int, username: non-empty-string} + */ +class UserService +{ + public const CONFIG_MAP = [ + 'mysql' => 'PDO\MySQL\Driver', + ]; + /** + * Resolves UserShape['id'] directly to positive-int + * + * @param UserShape['id'] $userId + * @param self::CONFIG_MAP['mysql'] $driverClass + */ + public function findUser(int $userId, string $driverClass): void + { + // ... + } +} + +$service = new UserService(); + +// Valid +$service->findUser(42, 'PDO\MySQL\Driver'); + +// Invalid $userId (-5 violates positive-int extracted from UserShape['id']) +$service->findUser(-5, 'PDO\MySQL\Driver'); +// Throws: TypeError: Argument $userId must be of type positive-int + +// Invalid $driverClass ('PDO\PgSQL\Driver' violates literal 'PDO\MySQL\Driver') +$service->findUser(42, 'PDO\PgSQL\Driver'); +// Throws: TypeError: Argument $driverClass must be literal 'PDO\MySQL\Driver' +``` --- ## Object Shapes (`object{prop: type}` & `stdClass{prop: type}`) diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md index a8ce431..81b66e8 100644 --- a/docs/supported-types/primitives-and-scalars.md +++ b/docs/supported-types/primitives-and-scalars.md @@ -112,6 +112,42 @@ setRange(150, 0, 5); --- +## Integer Bitmasks (`int-mask<...>` & `int-mask-of<...>`) + +TypePHP enforces bitwise flag combinations created by bitwise `OR` (`|`) operations on integers using `int-mask` and `int-mask-of`: + +| Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`int-mask<1, 2, 4>`** | Value must be a valid bitwise combination of the allowed integer flags (or `0`). | `0`, `1`, `3` (`1\|2`), `7` (`1\|2\|4`) | `8`, `10`, `-1` | +| **`int-mask-of`** | Value must be a valid bitwise combination of class constants matching the wildcard pattern. | `1`, `3`, `7` | `16` | + +```php +class BitmaskFlags +{ + public const FLAG_READ = 1; // 0001 + public const FLAG_WRITE = 2; // 0010 + public const FLAG_EXECUTE = 4; // 0100 + + /** + * @param int-mask<1, 2, 4> $mask + * @param int-mask-of $wildcardMask + */ + public static function setPermissions(int $mask, int $wildcardMask): void + { + // ... + } +} + +// Valid Call +BitmaskFlags::setPermissions(3, 7); // 3 = READ | WRITE, 7 = READ | WRITE | EXECUTE + +// Invalid (8 contains bits outside allowed mask 1|2|4 = 7) +BitmaskFlags::setPermissions(8, 7); +// Throws: TypeError: Argument $mask must be a valid bitmask combination of the allowed flags +``` + +--- + ## String Refinements Validate string lengths, formatting, character casing, and truthiness at runtime: @@ -122,6 +158,9 @@ Validate string lengths, formatting, character casing, and truthiness at runtime | **`numeric-string`** | `is_numeric($val) === true` | `'123'`, `'45.67'`, `'-10'` | `'abc'`, `''` | | **`lowercase-string`** | `strtolower($val) === $val` | `'hello'`, `'user_100'` | `'Hello'`, `'ADMIN'` | | **`non-empty-lowercase-string`** | Non-empty & lowercase | `'hello'`, `'abc'` | `''`, `'Hello'` | +| **`uppercase-string`** | `strtoupper($val) === $val` | `'USD'`, `'HELLO_100'` | `'Hello'`, `'admin'` | +| **`non-empty-uppercase-string`** | Non-empty & uppercase | `'EUR'`, `'ABC'` | `''`, `'Eur'` | +| **`array-key`** | `is_int($val) \|\| is_string($val)` | `100`, `'user_100'` | `true`, `[]`, `null` | | **`literal-string`** | String scalar | `'active'`, `'user'` | Non-strings | | **`truthy-string`**, **`non-falsy-string`** | Evaluates to `true` in boolean context | `'hello'`, `'1'` | `''`, `'0'` | diff --git a/docs/supported-types/type-aliases.md b/docs/supported-types/type-aliases.md new file mode 100644 index 0000000..2cfa125 --- /dev/null +++ b/docs/supported-types/type-aliases.md @@ -0,0 +1,177 @@ +# Type Aliases + +TypePHP supports declaring local type aliases (`@phpstan-type` / `@psalm-type`) and importing type aliases from other classes (`@phpstan-import-type` / `@psalm-import-type`). This allows you to centralize and reuse complex array shapes, unions, and generic structures across your application. + +> **Tooling Compatibility:** Both PHPStan syntax (`@phpstan-type`, `@phpstan-import-type`) and Psalm syntax (`@psalm-type`, `@psalm-import-type`) are parsed identically and enforced at runtime. + +--- + +## Local Type Aliases (`@phpstan-type` / `@psalm-type`) + +Declare a local type alias above a class or interface definition using `@phpstan-type` or `@psalm-type`. Once declared, you can reference the alias in any parameter, return, or `@var` docblock within that class: + +```php +updateUser(['id' => 10, 'username' => 'Alice', 'role' => 'admin'], 'active'); + +// Invalid Call ($id is negative, violating UserShape) +$service->updateUser(['id' => -5, 'username' => 'Alice', 'role' => 'admin'], 'active'); +// Throws: TypeError: UserService::updateUser(): Argument $user['id'] must be of type positive-int +``` + +--- + +## Imported Type Aliases (`@phpstan-import-type` / `@psalm-import-type`) + +To share type aliases across multiple classes, declare your aliases in a central class (e.g. `GlobalTypes`) and import them into other classes using `@phpstan-import-type` or `@psalm-import-type`: + +### Central Type Definitions (`GlobalTypes.php`) + +```php +namespace App\Types; + +/** + * Shared Type Definitions + * + * @phpstan-type SharedUserShape array{id: positive-int, email: non-empty-string} + * @psalm-type SharedRole 'admin'|'user' + */ +class GlobalTypes +{ +} +``` + +### Importing the Shared Type Alias (`UserApi.php`) + +```php +namespace App\Api; + +use App\Types\GlobalTypes; + +/** + * Import shared types from GlobalTypes + * + * @phpstan-import-type SharedUserShape from GlobalTypes + * @psalm-import-type SharedRole from GlobalTypes + */ +class UserApi +{ + /** + * @param SharedUserShape $user + * @param SharedRole $role + */ + public function saveUser(array $user, string $role): bool + { + return true; + } +} + +$api = new UserApi(); + +// Valid Call +$api->saveUser(['id' => 42, 'email' => 'alice@example.com'], 'admin'); + +// Invalid Call ($email is empty string) +$api->saveUser(['id' => 42, 'email' => ''], 'admin'); +// Throws: TypeError: UserApi::saveUser(): Argument $user['email'] must be of type non-empty-string +``` + +--- + +## Importing with Local Alias Renaming (`as`) + +Use the `as` keyword to rename an imported type alias locally to prevent naming collisions or improve local code clarity: + +```php +namespace App\Services; + +use App\Types\GlobalTypes; + +/** + * Import and rename the shared type alias + * + * @phpstan-import-type SharedUserShape from GlobalTypes as LocalUserShape + */ +class AccountService +{ + /** + * @param LocalUserShape $payload + */ + public function createAccount(array $payload): void + { + // ... + } +} +``` + +--- + +## Naming Collisions (When `as` is Omitted) + +If a class defines a local `@phpstan-type Status` AND imports a type alias with the exact same name (`@phpstan-import-type Status from GlobalTypes`) **without using the `as` keyword**: + +1. **Resolution Priority:** The imported type alias will **overwrite** the local type alias. +2. **Best Practice:** Always use the `as` keyword whenever an imported alias name collides with a local alias name to make your type contracts explicit: + +```php +/** + * Local alias: 'active'|'pending' + * @phpstan-type Status 'active'|'pending' + * + * Imported alias renamed to GlobalStatus to prevent overwriting local 'Status' + * @phpstan-import-type Status from GlobalTypes as GlobalStatus + */ +class OrderService +{ + // ... +} +``` + +--- + +## Chained Type Alias Imports + +TypePHP recursively resolves multi-level type alias import chains down to the root definition: + +* **Level 1 (`GlobalTypes`):** Defines `@phpstan-type UserShape array{id: positive-int}`. +* **Level 2 (`MidService`):** Imports `@phpstan-import-type UserShape from GlobalTypes`. +* **Level 3 (`FinalService`):** Imports `@phpstan-import-type UserShape from MidService as LocalShape`. + +When `FinalService` validates `$payload` against `LocalShape`, TypePHP automatically follows the 3-class import chain back to `GlobalTypes` and enforces `array{id: positive-int}`! + +```php +$service = new FinalService(); + +// Valid Call +$service->process(['id' => 100]); + +// Invalid Call (id is negative) +$service->process(['id' => -5]); +// Throws: TypeError: FinalService::process(): Argument $payload['id'] must be of type positive-int +``` diff --git a/docs/advanced/troubleshooting.md b/docs/troubleshooting.md similarity index 100% rename from docs/advanced/troubleshooting.md rename to docs/troubleshooting.md diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 2ef8142..b8bde46 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -12,6 +12,7 @@ use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; +use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use TypePHP\Internal\Config; @@ -263,14 +264,14 @@ private static function parseFunction(\ReflectionFunction $ref): array if ($isVariadic) { $type = new ArrayTypeNode($type); } - $resolvedType = SpecialTypeResolver::resolve($type, $ref); - $types[$paramName] = self::substituteAliases($resolvedType, $aliases); + $substitutedType = self::substituteAliases($type, $aliases); + $types[$paramName] = SpecialTypeResolver::resolve($substitutedType, $ref); } $returnTags = $phpDocNode->getReturnTagValues(); if (\count($returnTags) > 0) { - $resolvedReturn = SpecialTypeResolver::resolve($returnTags[0]->type, $ref); - $returnType = self::substituteAliases($resolvedReturn, $aliases); + $substitutedReturn = self::substituteAliases($returnTags[0]->type, $aliases); + $returnType = SpecialTypeResolver::resolve($substitutedReturn, $ref); } return [ @@ -331,10 +332,12 @@ private static function parseMethodHierarchyDocs( $hierarchy = HierarchyResolver::getMethodHierarchy($ref); $baseParams = $ref->getParameters(); $baseParamNames = []; + $baseParamSet = []; $baseParamVariadic = []; foreach ($baseParams as $idx => $p) { $baseParamNames[$idx] = $p->getName(); + $baseParamSet[$p->getName()] = $idx; $baseParamVariadic[$p->getName()] = $p->isVariadic(); } @@ -368,28 +371,32 @@ private static function parseMethodHierarchyDocs( foreach ($phpDocNode->getParamTagValues() as $paramTag) { $paramName = ltrim($paramTag->parameterName, '$'); - $paramIndex = $hierNameToIndex[$paramName] ?? null; - if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) { - $baseParamName = $baseParamNames[$paramIndex]; + if (isset($baseParamSet[$paramName])) { + $targetParamName = $paramName; + } else { + $paramIndex = $hierNameToIndex[$paramName] ?? null; + $targetParamName = ($paramIndex !== null && isset($baseParamNames[$paramIndex])) + ? $baseParamNames[$paramIndex] + : null; + } - if (! isset($types[$baseParamName])) { - $type = $paramTag->type; - $isVariadic = $paramTag->isVariadic || $baseParamVariadic[$baseParamName]; - if ($isVariadic) { - $type = new ArrayTypeNode($type); - } - $resolvedType = SpecialTypeResolver::resolve($type, $hierRef); - $types[$baseParamName] = self::substituteAliases($resolvedType, $aliases); + if ($targetParamName !== null && ! isset($types[$targetParamName])) { + $type = $paramTag->type; + $isVariadic = $paramTag->isVariadic || ($baseParamVariadic[$targetParamName] ?? false); + if ($isVariadic) { + $type = new ArrayTypeNode($type); } + $substitutedType = self::substituteAliases($type, $aliases); + $types[$targetParamName] = SpecialTypeResolver::resolve($substitutedType, $hierRef); } } if ($returnType === null) { $returnTags = $phpDocNode->getReturnTagValues(); if (\count($returnTags) > 0) { - $resolvedReturn = SpecialTypeResolver::resolve($returnTags[0]->type, $hierRef); - $returnType = self::substituteAliases($resolvedReturn, $aliases); + $substitutedReturn = self::substituteAliases($returnTags[0]->type, $aliases); + $returnType = SpecialTypeResolver::resolve($substitutedReturn, $hierRef); } } } @@ -441,6 +448,13 @@ private static function substituteAliases(TypeNode $node, array $aliases): TypeN return $node; } + if ($node instanceof OffsetAccessTypeNode) { + return new OffsetAccessTypeNode( + self::substituteAliases($node->type, $aliases), + self::substituteAliases($node->offset, $aliases) + ); + } + if ($node instanceof ArrayTypeNode) { return new ArrayTypeNode(self::substituteAliases($node->type, $aliases)); } diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 48bece3..a98636e 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -80,9 +80,17 @@ public static function checkVariable(mixed $value, string $typeString, string $v } if ($className !== null) { - $classAliases = ContractParser::parseClassAliases($className); - if (\count($classAliases) > 0) { - $typeNode = TemplateSubstitutor::substitute($typeNode, $classAliases); + if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + try { + $refClass = new \ReflectionClass($className); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); + + $classAliases = ContractParser::parseClassAliases($className); + if (\count($classAliases) > 0) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $classAliases); + } + } catch (\ReflectionException $e) { + } } } @@ -239,7 +247,29 @@ private static function shouldValidateType(TypeNode $node, array $config): bool return $checkArrays; } - if (\in_array($lower, ['int', 'integer', 'string', 'bool', 'boolean', 'float', 'double', 'null', 'true', 'false', 'scalar', 'numeric', 'positive-int', 'negative-int', 'non-empty-string', 'numeric-string', 'truthy', 'falsy'], true)) { + if (\in_array($lower, [ + 'int', + 'integer', + 'string', + 'bool', + 'boolean', + 'float', + 'double', + 'null', + 'true', + 'false', + 'scalar', + 'numeric', + 'positive-int', + 'negative-int', + 'non-empty-string', + 'numeric-string', + 'truthy', + 'falsy', + 'uppercase-string', + 'non-empty-uppercase-string', + 'array-key', + ], true)) { return (bool) ($config['scalars'] ?? false); } diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index e507061..77deb81 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -6,6 +6,8 @@ use PhpParser\Node\Stmt; use PhpParser\ParserFactory; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; @@ -20,6 +22,7 @@ use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; +use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode; use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; @@ -144,6 +147,68 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct ); } + if ($node instanceof OffsetAccessTypeNode) { + $baseType = self::resolve($node->type, $context, $thisObj); + $offsetType = self::resolve($node->offset, $context, $thisObj); + + $offsetKey = null; + if ($offsetType instanceof ConstTypeNode) { + $expr = $offsetType->constExpr; + if ($expr instanceof ConstExprStringNode) { + $offsetKey = $expr->value; + } elseif ($expr instanceof ConstExprIntegerNode) { + $offsetKey = (int) $expr->value; + } + } elseif ($offsetType instanceof IdentifierTypeNode) { + $offsetKey = $offsetType->name; + } + + if ($offsetKey !== null) { + if ($baseType instanceof ArrayShapeNode) { + foreach ($baseType->items as $item) { + $itemKey = null; + if ($item->keyName instanceof ConstExprStringNode) { + $itemKey = $item->keyName->value; + } elseif ($item->keyName instanceof IdentifierTypeNode) { + $itemKey = $item->keyName->name; + } elseif ($item->keyName instanceof ConstExprIntegerNode) { + $itemKey = (int) $item->keyName->value; + } + + if ((string) $itemKey === (string) $offsetKey) { + return $item->valueType; + } + } + } + + if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { + $constExpr = $baseType->constExpr; + $fqcn = $constExpr->className; + $constName = $constExpr->name; + + if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { + $val = $constValue[$offsetKey]; + if (\is_string($val)) { + return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); + } elseif (\is_int($val)) { + return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); + } + } + } + } catch (\ReflectionException $e) { + } + } + } + } + + return new OffsetAccessTypeNode($baseType, $offsetType); + } + if ($node instanceof ArrayShapeNode) { $items = array_map(function ($item) use ($context, $thisObj) { return new ArrayShapeItemNode( @@ -277,6 +342,68 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode ); } + if ($node instanceof OffsetAccessTypeNode) { + $baseType = self::resolveForFile($node->type, $file); + $offsetType = self::resolveForFile($node->offset, $file); + + $offsetKey = null; + if ($offsetType instanceof ConstTypeNode) { + $expr = $offsetType->constExpr; + if ($expr instanceof ConstExprStringNode) { + $offsetKey = $expr->value; + } elseif ($expr instanceof ConstExprIntegerNode) { + $offsetKey = (int) $expr->value; + } + } elseif ($offsetType instanceof IdentifierTypeNode) { + $offsetKey = $offsetType->name; + } + + if ($offsetKey !== null) { + if ($baseType instanceof ArrayShapeNode) { + foreach ($baseType->items as $item) { + $itemKey = null; + if ($item->keyName instanceof ConstExprStringNode) { + $itemKey = $item->keyName->value; + } elseif ($item->keyName instanceof IdentifierTypeNode) { + $itemKey = $item->keyName->name; + } elseif ($item->keyName instanceof ConstExprIntegerNode) { + $itemKey = (int) $item->keyName->value; + } + + if ((string) $itemKey === (string) $offsetKey) { + return $item->valueType; + } + } + } + + if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { + $constExpr = $baseType->constExpr; + $fqcn = $constExpr->className; + $constName = $constExpr->name; + + if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { + $val = $constValue[$offsetKey]; + if (\is_string($val)) { + return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); + } elseif (\is_int($val)) { + return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); + } + } + } + } catch (\ReflectionException $e) { + } + } + } + } + + return new OffsetAccessTypeNode($baseType, $offsetType); + } + if ($node instanceof ArrayShapeNode) { $items = array_map(function ($item) use ($file) { return new ArrayShapeItemNode( @@ -523,14 +650,70 @@ public static function resolveFqcnForFile(string $name, string $file): string private static function isBuiltInTypeKeyword(string $name): bool { return \in_array(strtolower($name), [ - 'int', 'integer', 'string', 'float', 'double', 'bool', 'boolean', 'array', 'list', 'object', 'callable', - 'iterable', 'resource', 'null', 'true', 'false', 'mixed', 'scalar', 'void', 'self', 'static', 'parent', '$this', - 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int', 'unsigned-int', - 'positive-float', 'negative-float', 'non-positive-float', 'non-negative-float', 'non-zero-float', - 'class-string', 'interface-string', 'trait-string', 'enum-string', 'callable-string', 'numeric-string', - 'non-empty-string', 'lowercase-string', 'non-empty-lowercase-string', 'literal-string', 'truthy-string', - 'non-empty-array', 'non-empty-list', 'number', 'numeric', 'truthy', 'falsy', 'falsey', 'min', 'max', '*', - 'never', 'never-return', 'never-returns', 'no-return', 'open-resource', 'closed-resource', + 'int', + 'integer', + 'string', + 'float', + 'double', + 'bool', + 'boolean', + 'array', + 'list', + 'object', + 'callable', + 'iterable', + 'resource', + 'null', + 'true', + 'false', + 'mixed', + 'scalar', + 'void', + 'self', + 'static', + 'parent', + '$this', + 'positive-int', + 'negative-int', + 'non-positive-int', + 'non-negative-int', + 'non-zero-int', + 'unsigned-int', + 'positive-float', + 'negative-float', + 'non-positive-float', + 'non-negative-float', + 'non-zero-float', + 'class-string', + 'interface-string', + 'trait-string', + 'enum-string', + 'callable-string', + 'numeric-string', + 'non-empty-string', + 'lowercase-string', + 'non-empty-lowercase-string', + 'uppercase-string', + 'non-empty-uppercase-string', + 'array-key', + 'literal-string', + 'truthy-string', + 'non-empty-array', + 'non-empty-list', + 'number', + 'numeric', + 'truthy', + 'falsy', + 'falsey', + 'min', + 'max', + '*', + 'never', + 'never-return', + 'never-returns', + 'no-return', + 'open-resource', + 'closed-resource', ], true); } diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index 5ef417f..d822ff5 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -19,7 +19,7 @@ use TypePHP\Internal\TypeFormatter; /** - * @internal values against generic AST structures (int ranges, class-string, list, array, object generics). + * @internal Validates values against generic AST structures (int ranges, class-string, list, array, object generics, key-of, value-of, int-mask, int-mask-of). */ final class GenericValidator implements TypeValidatorInterface { @@ -38,6 +38,9 @@ final class GenericValidator implements TypeValidatorInterface */ private static array $enumValueCache = []; + /** + * Validates a value against a GenericTypeNode AST. + */ public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage { /** @var GenericTypeNode $genericNode */ @@ -51,10 +54,43 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'iterator' => $this->validateArray($value, $genericNode, $context, $registry), 'key-of' => $this->validateKeyOf($value, $genericNode, $context), 'value-of' => $this->validateValueOf($value, $genericNode, $context), + 'int-mask' => $this->validateIntMask($value, $genericNode, $context), + 'int-mask-of' => $this->validateIntMaskOf($value, $genericNode, $context), default => $this->validateObjectGeneric($value, $genericNode, $context), }; } + /** + * Helper to resolve and cache class or global constant values in static memory. + */ + private function resolveConstantValue(string $fqcn, string $constName): mixed + { + $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; + + if (! \array_key_exists($cacheKey, self::$constantCache)) { + $constValue = false; + if ($fqcn !== '') { + if (class_exists($fqcn) || interface_exists($fqcn)) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + } + } catch (\ReflectionException $e) { + // Silently ignore reflection errors + } + } + } else { + if (\defined($constName)) { + $constValue = \constant($constName); + } + } + self::$constantCache[$cacheKey] = $constValue; + } + + return self::$constantCache[$cacheKey]; + } + /** * Validates key-of generic structures with O(1) in-memory caching. * @@ -78,27 +114,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont $constName = $constExpr->name; $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; - if (! \array_key_exists($cacheKey, self::$constantCache)) { - $constValue = false; - if ($fqcn !== '') { - if (class_exists($fqcn) || interface_exists($fqcn)) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - } - } catch (\ReflectionException $e) { - } - } - } else { - if (\defined($constName)) { - $constValue = \constant($constName); - } - } - self::$constantCache[$cacheKey] = $constValue; - } - - $constValue = self::$constantCache[$cacheKey]; + $constValue = $this->resolveConstantValue($fqcn, $constName); if (\is_array($constValue)) { if ((! \is_int($value) && ! \is_string($value)) || ! \array_key_exists($value, $constValue)) { @@ -163,27 +179,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co $constName = $constExpr->name; $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; - if (! \array_key_exists($cacheKey, self::$constantCache)) { - $constValue = false; - if ($fqcn !== '') { - if (class_exists($fqcn) || interface_exists($fqcn)) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - } - } catch (\ReflectionException $e) { - } - } - } else { - if (\defined($constName)) { - $constValue = \constant($constName); - } - } - self::$constantCache[$cacheKey] = $constValue; - } - - $constValue = self::$constantCache[$cacheKey]; + $constValue = $this->resolveConstantValue($fqcn, $constName); if (\is_array($constValue)) { if (! \in_array($value, $constValue, true)) { @@ -210,6 +206,95 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co return null; } + /** + * Validates int-mask<1, 2, 4> bitmask flags combinations. + */ + private function validateIntMask(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + { + if (! \is_int($value)) { + return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + + $allowedMask = 0; + + foreach ($node->genericTypes as $typeNode) { + if ($typeNode instanceof ConstTypeNode) { + $expr = $typeNode->constExpr; + if ($expr instanceof ConstExprIntegerNode) { + $allowedMask |= (int) $expr->value; + } elseif ($expr instanceof ConstFetchNode) { + $constVal = $this->resolveConstantValue($expr->className, $expr->name); + if (\is_int($constVal)) { + $allowedMask |= $constVal; + } + } + } + } + + if (($value & ~$allowedMask) !== 0) { + return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; + } + + /** + * Validates int-mask-of bitmask flags combinations from constant patterns. + */ + private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + { + if (! \is_int($value)) { + return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + + $targetType = $node->genericTypes[0] ?? null; + $allowedMask = 0; + $foundFlags = false; + + if ($targetType instanceof ConstTypeNode && $targetType->constExpr instanceof ConstFetchNode) { + $constExpr = $targetType->constExpr; + $fqcn = $constExpr->className; + $pattern = $constExpr->name; + + if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { + try { + $refClass = new \ReflectionClass($fqcn); + + if (str_contains($pattern, '*')) { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i'; + foreach ($refClass->getConstants() as $cName => $cValue) { + if (\is_int($cValue) && preg_match($regex, $cName) === 1) { + $allowedMask |= $cValue; + $foundFlags = true; + } + } + } else { + $cValue = $this->resolveConstantValue($fqcn, $pattern); + if (\is_int($cValue)) { + $allowedMask |= $cValue; + $foundFlags = true; + } elseif (\is_array($cValue)) { + foreach ($cValue as $item) { + if (\is_int($item)) { + $allowedMask |= $item; + $foundFlags = true; + } + } + } + } + } catch (\ReflectionException $e) { + // Silently ignore reflection errors + } + } + } + + if ($foundFlags && ($value & ~$allowedMask) !== 0) { + return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; + } + /** * Validates integer ranges (e.g. int<1, 100> or int). */ @@ -365,9 +450,14 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont /** * Validates object generic instances and binds template parameters. + * Gracefully ignores generic annotations with invalid class syntax (e.g. custom-generic). */ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage { + if (! ClassNameValidator::isValid($node->type->name)) { + return null; + } + if (! \is_object($value)) { return ErrorFactory::createError($context . ' must be an object of type ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); } diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index c820172..4b47815 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -62,6 +62,9 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'non-empty-string' => \is_string($value) && $value !== '', 'lowercase-string' => \is_string($value) && strtolower($value) === $value, 'non-empty-lowercase-string' => \is_string($value) && $value !== '' && strtolower($value) === $value, + 'uppercase-string' => \is_string($value) && strtoupper($value) === $value, + 'non-empty-uppercase-string' => \is_string($value) && $value !== '' && strtoupper($value) === $value, + 'array-key' => \is_int($value) || \is_string($value), 'literal-string' => \is_string($value), 'truthy-string', 'non-falsy-string' => \is_string($value) && (bool) $value === true, 'non-empty-array' => \is_array($value) && \count($value) > 0, diff --git a/tests/Fixtures/Attributes/BaseField.php b/tests/Fixtures/Attributes/BaseField.php new file mode 100644 index 0000000..d919a39 --- /dev/null +++ b/tests/Fixtures/Attributes/BaseField.php @@ -0,0 +1,22 @@ +|null + */ + #[OneToManyRelation( + entity: 'measurement_display_unit', + ref: 'measurement_system_id', + onDelete: OnDeleteOption::CASCADE, + api: true + )] + public ?array $units = null; +} diff --git a/tests/Fixtures/Attributes/OnDeleteOption.php b/tests/Fixtures/Attributes/OnDeleteOption.php new file mode 100644 index 0000000..c73a6e0 --- /dev/null +++ b/tests/Fixtures/Attributes/OnDeleteOption.php @@ -0,0 +1,11 @@ + $userId, $name -> $userName, $role -> $userRole + * and adds an optional parameter $notify + * + * @param bool $notify + */ + public function registerUser(int $userId, string $userName, string $userRole = 'user', bool $notify = false): bool + { + return parent::registerUser($userId, $userName, $userRole); + } +} diff --git a/tests/Fixtures/Types/BitmaskFlags.php b/tests/Fixtures/Types/BitmaskFlags.php new file mode 100644 index 0000000..a89b0d7 --- /dev/null +++ b/tests/Fixtures/Types/BitmaskFlags.php @@ -0,0 +1,28 @@ + $mask + */ + public static function checkLiteralMask(int $mask): int + { + return $mask; + } + + /** + * @param int-mask-of $mask + */ + public static function checkWildcardMask(int $mask): int + { + return $mask; + } +} diff --git a/tests/Fixtures/Types/CurrencyFormatter.php b/tests/Fixtures/Types/CurrencyFormatter.php new file mode 100644 index 0000000..30833b7 --- /dev/null +++ b/tests/Fixtures/Types/CurrencyFormatter.php @@ -0,0 +1,27 @@ + 'PDO\MySQL\Driver', + ]; + + /** + * @param UserShape['id'] $id + */ + public function setUserId(int $id): int + { + return $id; + } + + /** + * @param self::CONFIG_MAP['mysql'] $driverClass + */ + public static function setDriver(string $driverClass): string + { + return $driverClass; + } +} diff --git a/tests/TypeChecking/AttributeConstructorInheritanceTest.php b/tests/TypeChecking/AttributeConstructorInheritanceTest.php new file mode 100644 index 0000000..ee295c1 --- /dev/null +++ b/tests/TypeChecking/AttributeConstructorInheritanceTest.php @@ -0,0 +1,64 @@ +toBeInstanceOf(OneToManyRelation::class); + }); + + test('reproduces parameter index mismatch bug when instantiated via PHP 8 ReflectionAttribute::newInstance()', function () { + $refProp = new ReflectionProperty(MeasurementSystemEntity::class, 'units'); + $refAttr = $refProp->getAttributes(OneToManyRelation::class)[0]; + + $attrInstance = $refAttr->newInstance(); + + expect($attrInstance)->toBeInstanceOf(OneToManyRelation::class); + }); + + }); + + describe('Multi-Level 3-Tier Parameter Shift Edge Cases', function () { + + test('correctly maps inherited contracts across 3 hierarchy levels with multiple position shifts', function () { + $field = new DeepMultiLevelField(10, 'entity', 'unit_name', true); + + expect($field)->toBeInstanceOf(DeepMultiLevelField::class); + }); + + test('throws TypeError when $id violates local child contract in 3-tier hierarchy', function () { + expect(fn () => new DeepMultiLevelField(-5, 'entity', 'unit_name', true)) + ->toThrow(TypeError::class, 'Argument $id must be of type positive-int') + ; + }); + + test('throws TypeError when $type violates 3rd-tier grand-parent contract', function () { + expect(fn () => new DeepMultiLevelField(10, '', 'unit_name', true)) + ->toThrow(TypeError::class, 'Argument $type must be of type non-empty-string') + ; + }); + + test('throws TypeError when $name violates 2nd-tier parent contract', function () { + expect(fn () => new DeepMultiLevelField(10, 'entity', '', true)) + ->toThrow(TypeError::class, 'Argument $name must be of type non-empty-string') + ; + }); + + }); + +}); diff --git a/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php b/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php index 0cb2978..b45e245 100644 --- a/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php +++ b/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php @@ -12,6 +12,16 @@ function testUnsupportedTypeSyntax(string $specialType): string return $specialType; } +/** + * Custom generic annotation with invalid class syntax (contains hyphens) + * + * @param custom-generic $customGeneric + */ +function testUnsupportedGenericSyntax(string $customGeneric): string +{ + return $customGeneric; +} + /** * Valid PHP class name syntax for a class that does not exist at runtime * @@ -23,9 +33,8 @@ function testNonExistentClassType(mixed $param): mixed } test('ignores custom unsupported type syntax with hyphens gracefully', function () { - $result = testUnsupportedTypeSyntax('special-type'); - - expect($result)->toBe('special-type'); + expect(testUnsupportedTypeSyntax('special-type'))->toBe('special-type'); + expect(testUnsupportedGenericSyntax('hello'))->toBe('hello'); }); test('strictly validates valid class syntax even if class does not exist at runtime', function () { diff --git a/tests/TypeChecking/InlineVariableValidationTest.php b/tests/TypeChecking/InlineVariableValidationTest.php index b85041c..dba6320 100644 --- a/tests/TypeChecking/InlineVariableValidationTest.php +++ b/tests/TypeChecking/InlineVariableValidationTest.php @@ -233,3 +233,50 @@ function fetchBroadTuple(int $id, string $name): array expect($token)->toBe('valid_token'); }); }); + +describe('Inline @var Validation for New Features', function () { + + test('enforces array-key on inline local variables', function () { + /** @var array-key $key */ + $key = 'user_123'; + expect($key)->toBe('user_123'); + + $key = 456; + expect($key)->toBe(456); + + expect(fn () => $key = false) + ->toThrow(TypeError::class, 'Variable $key must be of type array-key') + ; + }); + + test('enforces uppercase-string on inline local variables', function () { + /** @var non-empty-uppercase-string $code */ + $code = 'USD'; + expect($code)->toBe('USD'); + + expect(fn () => $code = 'usd') + ->toThrow(TypeError::class, 'Variable $code must be of type non-empty-uppercase-string') + ; + }); + + test('enforces key-of on inline local variables', function () { + /** @var key-of $driver */ + $driver = 'pdo_mysql'; + expect($driver)->toBe('pdo_mysql'); + + expect(fn () => $driver = 'pdo_invalid') + ->toThrow(TypeError::class, 'Variable $driver') + ; + }); + + test('enforces int-mask on inline local variables', function () { + /** @var int-mask<1, 2, 4> $mask */ + $mask = 3; // 1 | 2 + expect($mask)->toBe(3); + + expect(fn () => $mask = 8) + ->toThrow(TypeError::class, 'Variable $mask') + ; + }); + +}); diff --git a/tests/TypeChecking/IntMaskTest.php b/tests/TypeChecking/IntMaskTest.php new file mode 100644 index 0000000..4d12888 --- /dev/null +++ b/tests/TypeChecking/IntMaskTest.php @@ -0,0 +1,78 @@ + $mask + */ +function testLiteralIntMask(int $mask): int +{ + return $mask; +} + +describe('int-mask and int-mask-of Annotations', function () { + + describe('int-mask<1, 2, 4>', function () { + + test('accepts valid bitmask flag combinations and zero', function () { + expect(testLiteralIntMask(0))->toBe(0); // No flags + expect(testLiteralIntMask(1))->toBe(1); // READ + expect(testLiteralIntMask(3))->toBe(3); // READ | WRITE + expect(testLiteralIntMask(7))->toBe(7); // READ | WRITE | EXECUTE + expect(BitmaskFlags::checkLiteralMask(5))->toBe(5); // READ | EXECUTE + }); + + test('throws TypeError when bitmask contains illegal bits', function () { + expect(fn () => testLiteralIntMask(8)) // 8 (1000) is outside allowed mask 7 (0111) + ->toThrow(TypeError::class, 'must be a valid bitmask combination') + ; + + expect(fn () => BitmaskFlags::checkLiteralMask(10)) + ->toThrow(TypeError::class, 'must be a valid bitmask combination') + ; + }); + }); + + describe('int-mask-of', function () { + + test('accepts valid bitmask combinations matching wildcard constants', function () { + expect(BitmaskFlags::checkWildcardMask(1))->toBe(1); + expect(BitmaskFlags::checkWildcardMask(3))->toBe(3); + expect(BitmaskFlags::checkWildcardMask(7))->toBe(7); + }); + + test('throws TypeError on invalid bitmask for wildcard constants', function () { + expect(fn () => BitmaskFlags::checkWildcardMask(16)) + ->toThrow(TypeError::class, 'must be a valid bitmask combination') + ; + }); + }); + + describe('Inline @var Constant Bitmasks', function () { + + test('enforces int-mask with specific class constants on inline variables', function () { + /** @var int-mask $mask */ + $mask = 1; + expect($mask)->toBe(1); + + $mask = 3; + expect($mask)->toBe(3); + + expect(fn () => $mask = 4) + ->toThrow(TypeError::class, 'Variable $mask must be a valid bitmask combination') + ; + }); + + test('enforces int-mask-of with wildcard constant patterns on inline variables', function () { + /** @var int-mask-of $wildcardMask */ + $wildcardMask = 7; + expect($wildcardMask)->toBe(7); + + expect(fn () => $wildcardMask = 16) + ->toThrow(TypeError::class, 'Variable $wildcardMask must be a valid bitmask combination') + ; + }); + }); +}); diff --git a/tests/TypeChecking/KeyOfValueOfTest.php b/tests/TypeChecking/KeyOfValueOfTest.php index 18a0502..42eb786 100644 --- a/tests/TypeChecking/KeyOfValueOfTest.php +++ b/tests/TypeChecking/KeyOfValueOfTest.php @@ -153,7 +153,8 @@ function testEnumValueOf(string $statusValue): string expect($conn->localAction(['action' => 'start']))->toBeTrue(); expect(fn () => $conn->localAction(['action' => 'pause'])) - ->toThrow(TypeError::class, "['action'] must be a key of TypePHP\Tests\Fixtures\Types\DoctrineLikeConnection::LOCAL_ACTIONS"); + ->toThrow(TypeError::class, "['action'] must be a key of TypePHP\Tests\Fixtures\Types\DoctrineLikeConnection::LOCAL_ACTIONS") + ; }); }); diff --git a/tests/TypeChecking/NamedArgumentsTest.php b/tests/TypeChecking/NamedArgumentsTest.php new file mode 100644 index 0000000..a44a3d3 --- /dev/null +++ b/tests/TypeChecking/NamedArgumentsTest.php @@ -0,0 +1,63 @@ + $age + */ +function testNamedArgsFunction(int $id, string $username, int $age): bool +{ + return true; +} + +describe('PHP 8.0+ Named Arguments and Renamed Parameter Positions', function () { + + describe('Standalone Function with Swapped Named Arguments', function () { + test('accepts valid named arguments passed in completely reversed/swapped order', function () { + expect(testNamedArgsFunction(age: 25, username: 'Alice', id: 42))->toBeTrue(); + }); + + test('throws TypeError on invalid named argument regardless of passed argument position', function () { + expect(fn () => testNamedArgsFunction(age: 25, username: 'Alice', id: -5)) + ->toThrow(TypeError::class, 'Argument $id must be of type positive-int') + ; + + expect(fn () => testNamedArgsFunction(username: '', id: 42, age: 25)) + ->toThrow(TypeError::class, 'Argument $username must be of type non-empty-string') + ; + + expect(fn () => testNamedArgsFunction(id: 42, age: 150, username: 'Alice')) + ->toThrow(TypeError::class, 'Argument $age') + ; + }); + + }); + + describe('Class Method with Renamed Parameters in Method Inheritance', function () { + + test('correctly maps inherited contracts when child renames parameters and is called with named arguments in random order', function () { + $service = new ShiftedParamService(); + expect($service->registerUser(notify: true, userRole: 'admin', userId: 100, userName: 'Bob'))->toBeTrue(); + }); + + test('throws TypeError on invalid parameter when child renames parameters in method inheritance', function () { + $service = new ShiftedParamService(); + + expect(fn () => $service->registerUser(userRole: 'admin', userName: 'Bob', userId: -10)) + ->toThrow(TypeError::class, 'Argument $userId must be of type positive-int') + ; + + expect(fn () => $service->registerUser(userRole: 'superadmin', userName: 'Bob', userId: 100)) + ->toThrow(TypeError::class, 'Argument $userRole') + ; + }); + + }); + +}); diff --git a/tests/TypeChecking/OffsetAccessTest.php b/tests/TypeChecking/OffsetAccessTest.php new file mode 100644 index 0000000..75dab54 --- /dev/null +++ b/tests/TypeChecking/OffsetAccessTest.php @@ -0,0 +1,44 @@ +setUserId(42))->toBe(42); + + expect(fn () => $container->setUserId(-5)) + ->toThrow(TypeError::class, 'must be of type positive-int') + ; + }); + + test('resolves constant offset from self::CONFIG_MAP[\'mysql\'] to literal string', function () { + expect(OffsetAccessContainer::setDriver('PDO\MySQL\Driver'))->toBe('PDO\MySQL\Driver'); + + expect(fn () => OffsetAccessContainer::setDriver('PDO\PgSQL\Driver')) + ->toThrow(TypeError::class, 'must be literal') + ; + }); + + test('resolves direct inline shape offset array{...}[\'status\'] to union status', function () { + expect(testDirectShapeOffset('active'))->toBe('active'); + expect(testDirectShapeOffset('pending'))->toBe('pending'); + + expect(fn () => testDirectShapeOffset('archived')) + ->toThrow(TypeError::class, "('active' | 'pending')") + ; + }); + +}); diff --git a/tests/TypeChecking/UppercaseAndArrayKeyTest.php b/tests/TypeChecking/UppercaseAndArrayKeyTest.php new file mode 100644 index 0000000..e585925 --- /dev/null +++ b/tests/TypeChecking/UppercaseAndArrayKeyTest.php @@ -0,0 +1,104 @@ +toBe(100); + expect(testArrayKeyParam('user_100'))->toBe('user_100'); + }); + + test('throws TypeError when array-key is a boolean or array', function () { + expect(fn () => testArrayKeyParam(true)) + ->toThrow(TypeError::class, 'must be of type array-key') + ; + + expect(fn () => testArrayKeyParam([])) + ->toThrow(TypeError::class, 'must be of type array-key') + ; + }); + + }); + + describe('uppercase-string & non-empty-uppercase-string', function () { + + test('accepts valid uppercase strings', function () { + expect(testUppercaseStringParam('USD'))->toBe('USD'); + expect(testUppercaseStringParam(''))->toBe(''); + expect(testNonEmptyUppercaseStringParam('EUR'))->toBe('EUR'); + }); + + test('throws TypeError on lowercase or mixed-case string for uppercase-string', function () { + expect(fn () => testUppercaseStringParam('Usd')) + ->toThrow(TypeError::class, 'must be of type uppercase-string') + ; + + expect(fn () => testUppercaseStringParam('usd')) + ->toThrow(TypeError::class, 'must be of type uppercase-string') + ; + }); + + test('throws TypeError on empty string for non-empty-uppercase-string', function () { + expect(fn () => testNonEmptyUppercaseStringParam('')) + ->toThrow(TypeError::class, 'must be of type non-empty-uppercase-string') + ; + }); + + }); + + describe('Fixture Class Method Verification (CurrencyFormatter)', function () { + + test('validates parameters on CurrencyFormatter class methods', function () { + $formatter = new CurrencyFormatter(); + + expect($formatter->formatAccount('USD', 101))->toBe('USD_101'); + expect($formatter->formatAccount('GBP', 'acc_202'))->toBe('GBP_acc_202'); + + expect(fn () => $formatter->formatAccount('usd', 101)) + ->toThrow(TypeError::class, 'must be of type non-empty-uppercase-string') + ; + + expect(fn () => $formatter->formatAccount('USD', true)) + ->toThrow(TypeError::class, 'must be of type array-key') + ; + }); + + test('validates static method returns on CurrencyFormatter', function () { + expect(CurrencyFormatter::sanitizeCode('CAD'))->toBe('CAD'); + + expect(fn () => CurrencyFormatter::sanitizeCode('cad')) + ->toThrow(TypeError::class, 'Return value'); + }); + + }); + +}); diff --git a/tests/Unit/ValidatorsTest.php b/tests/Unit/ValidatorsTest.php index 4cd621a..5062bf5 100644 --- a/tests/Unit/ValidatorsTest.php +++ b/tests/Unit/ValidatorsTest.php @@ -124,6 +124,26 @@ function parseType(string $typeString, Lexer $lexer, TypeParser $typeParser): Ty $neverNode = parseType('never', $this->lexer, $this->typeParser); expect($this->registry->validate('returned_value', $neverNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); }); + + test('validates array-key pseudo-type (int|string)', function () { + $arrayKeyNode = parseType('array-key', $this->lexer, $this->typeParser); + + expect($this->registry->validate(123, $arrayKeyNode, 'arg'))->toBeNull(); + expect($this->registry->validate('custom_key', $arrayKeyNode, 'arg'))->toBeNull(); + expect($this->registry->validate(true, $arrayKeyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate([], $arrayKeyNode, 'arg'))->toBeInstanceOf(ErrorMessage::class); + }); + + test('validates uppercase-string and non-empty-uppercase-string', function () { + $uppercase = parseType('uppercase-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('USD', $uppercase, 'arg'))->toBeNull(); + expect($this->registry->validate('hello', $uppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class); + + $nonEmptyUppercase = parseType('non-empty-uppercase-string', $this->lexer, $this->typeParser); + expect($this->registry->validate('EUR', $nonEmptyUppercase, 'arg'))->toBeNull(); + expect($this->registry->validate('', $nonEmptyUppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class); + expect($this->registry->validate('eur', $nonEmptyUppercase, 'arg'))->toBeInstanceOf(ErrorMessage::class); + }); }); describe('ConstValidator', function () {