diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 9d56b0a..fecb278 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -9,7 +9,7 @@ export default defineConfig({ nav: [ { text: 'Home', link: '/' }, { text: 'Documentation', link: '/getting-started/installation' }, - { text: 'Generics', link: '/generics/generics-and-bounds' }, + { text: 'Generics', link: '/generics/basics-and-bounds' }, { text: 'CLI', link: '/getting-started/cli-commands' }, { text: 'FAQ', link: '/troubleshooting' }, { text: 'GitHub', link: 'https://github.com/typephp-php/typephp' } @@ -24,6 +24,16 @@ export default defineConfig({ { text: 'CLI Commands', link: '/getting-started/cli-commands' }, ] }, + { + text: 'Runtime Generics', + items: [ + { text: 'Basics & Bounds', link: '/generics/basics-and-bounds' }, + { text: 'Inheritance & Traits', link: '/generics/inheritance-and-traits' }, + { text: 'Reified Generics & State', link: '/generics/reified-generics' }, + { text: 'Advanced Types & Callables', link: '/generics/advanced-generics' }, + { text: 'Demystifying Variance', link: '/generics/variance' }, + ] + }, { text: 'Enforcement Boundaries', items: [ @@ -44,12 +54,6 @@ export default defineConfig({ { text: 'Type Aliases', link: '/supported-types/type-aliases' }, ] }, - { - text: 'Runtime Generics', - items: [ - { text: 'Generics & Bounds', link: '/generics/generics-and-bounds' }, - ] - }, { text: 'Advanced & Architecture', items: [ diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..c7881a7 --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,34 @@ +.VPDocAsideOutline .outline-link, +.VPDocOutlineItem .outline-link { + white-space: normal !important; + line-height: 1.5 !important; + word-break: break-word !important; + padding-top: 6px !important; + padding-bottom: 6px !important; + display: block !important; +} + +.VPDocAsideOutline .outline-item, +.VPDocOutlineItem, +.VPDocAsideOutline ul > li { + margin-top: 6px !important; + margin-bottom: 8px !important; +} + +.VPDocAsideOutline .nested { + padding-left: 14px !important; + margin-top: 4px !important; + margin-bottom: 4px !important; +} + +.VPSidebarItem.is-link .text { + white-space: normal !important; + line-height: 1.4 !important; + word-break: break-word !important; +} + +@media (min-width: 1280px) { + .VPDoc.has-aside .aside-container { + width: 270px !important; + } +} \ No newline at end of file diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..4a85815 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,7 @@ +import DefaultTheme from 'vitepress/theme' +import type { Theme } from 'vitepress' +import './custom.css' + +export default { + extends: DefaultTheme, +} satisfies Theme \ No newline at end of file diff --git a/docs/generics/advanced-generics.md b/docs/generics/advanced-generics.md new file mode 100644 index 0000000..c95bb67 --- /dev/null +++ b/docs/generics/advanced-generics.md @@ -0,0 +1,269 @@ +# Advanced Types & Callables + +TypePHP allows combining generic templates (`T`, `K`, `V`) with high-level type algebra, including Higher-Order Callables, Lazy Iterables, Generators, Conditionals, Unions, Intersections, and Deeply Nested Containers. + +--- + +## Generic Callables with Template Substitution + +When a function accepts a generic callback (`@param callable(T): T $transformer`), TypePHP dynamically substitutes `T` with the inferred concrete type before callback invocation: + +```php +/** + * Generic transformer function + * + * @template T + * + * @param callable(T): T $transformer + * @param T $input + * + * @return T + */ +function transformValue(callable $transformer, mixed $input): mixed +{ + return $transformer($input); +} + +// 1. Valid Call: Infers T = int, validates callback argument (int) and return (int) +$double = fn (int $x): int => $x * 2; +transformValue($double, 21); // Returns 42 + +// 2. Invalid Callback Return: T is inferred as int (from 10), but callback returns string ('invalid') +$badReturn = fn (int $x): string => 'invalid'; +transformValue($badReturn, 10); +// Throws: TypeError: transformValue(): Return value must be of type int, string 'invalid' returned +``` + +--- + +## Higher-Order Generic Transformers (`array` & `callable(V): V2`) + +TypePHP pre-infers template parameters across multiple arguments simultaneously: + +```php +/** + * Higher-order array mapper with 3 generic parameters + * + * @template K of array-key + * @template V + * @template V2 + * + * @param callable(V): V2 $callback + * @param array $array + * + * @return array + */ +function mapArray(callable $callback, array $array): array +{ + $result = []; + foreach ($array as $key => $value) { + $result[$key] = $callback($value); + } + + return $result; +} + +$stringify = fn (int $n): string => "val_{$n}"; + +// 1. Valid Call: Infers K = string, V = int, V2 = string +$res = mapArray($stringify, ['a' => 10, 'b' => 20]); +// Returns: ['a' => 'val_10', 'b' => 'val_20'] + +// 2. Invalid Call: 'invalid_string' violates inferred V = int on function entry! +mapArray($stringify, ['item1' => 10, 'item2' => 'invalid_string']); +// Throws: TypeError: mapArray(): Argument $array['item2'] must be of type int, string 'invalid_string' given +``` + +--- + +## Generic Iterables & Generators (`iterable` & `Generator`) + +TypePHP substitutes template parameters into iterators, validating yielded items, keys, and generator inputs (`$gen->send()`) lazily during execution: + +```php +/** + * @template T + * + * @param iterable $stream + * @param T $sample + * + * @return list + */ +function collectStream(iterable $stream, mixed $sample): array +{ + $collected = []; + foreach ($stream as $item) { + $collected[] = $item; + } + return $collected; +} + +// Infers T = int from $sample (1) +$iterator = new ArrayIterator([10, 'invalid', 30]); +collectStream($iterator, 1); +// Throws: TypeError: Iterator $stream value must be of type int, string 'invalid' given +``` + +### Generic Interactive Generators (`Generator` / `TSend`) + +When `TSend` uses a generic template `T`, `$gen->send()` is dynamically validated against the bound generic type: + +```php +/** + * @template T + * + * @param T $initial + * + * @return Generator + */ +function streamInteractive(mixed $initial): Generator +{ + $current = $initial; + for ($i = 0; $i < 3; $i++) { + $input = yield $i => $current; + if ($input !== null) { + $current = $input; + } + } +} + +// Initial value 10 locks T = int +$gen = streamInteractive(10); +$gen->current(); + +$gen->send(20); // Valid (20 is int) + +$gen->send('invalid'); // Invalid: string violates T = int! +// Throws: TypeError: streamInteractive(): Generator sent value (TSend) must be of type int, string 'invalid' given +``` + +--- + +## Conditional Return Types with Generics (`(T is Dog ? A : B)`) + +TypePHP dynamically evaluates conditional return types based on generic templates: + +```php +/** + * @template T + * + * @param T $input + * @param mixed $output + * + * @return (T is Dog ? positive-int : non-empty-string) + */ +function processInput(mixed $input, mixed $output): mixed +{ + return $output; +} + +// 1. T is inferred as Dog -> Evaluates return contract as positive-int +processInput(new Dog(), 100); // Valid + +// 2. T is inferred as Cat -> Evaluates return contract as non-empty-string +processInput(new Cat(), 'valid_string'); // Valid + +processInput(new Cat(), ''); // Invalid: empty string violates non-empty-string +// Throws: TypeError: processInput(): Return value must be of type non-empty-string +``` + +### Negated Generic Conditionals (`(T is not Dog ? A : B)`) + +```php +/** + * @template T + * + * @param T $input + * @param mixed $result + * + * @return (T is not Dog ? non-empty-string : positive-int) + */ +function processNegated(mixed $input, mixed $result): mixed +{ + return $result; +} + +processNegated(new Cat(), 'valid_text'); // Valid (Cat is not Dog -> non-empty-string) +processNegated(new Dog(), 42); // Valid (Dog is Dog -> positive-int) +``` + +--- + +## Generics with Unions and Intersections + +TypePHP fully supports combining generic structures with Union (`|`) and Intersection (`&`) types: + +### Generic Containers Holding Unions (`Collection`) + +```php +/** @var Collection $animals */ +$animals = new Collection(); + +$animals->add(new Dog()); // Valid +$animals->add(new Cat()); // Valid + +$animals->add(new Car()); // Invalid: Car is neither Dog nor Cat +// Throws: TypeError: Collection::add(): Argument $item (template T = Dog|Cat) must be of type (Dog | Cat) +``` + +### Unions of Generic Containers (`Producer | Producer`) + +```php +/** + * @param Producer|Producer $producer + */ +function handleAnimalProducer(Producer $producer): void +{ + // ... +} + +handleAnimalProducer(new Producer(new Dog())); // Valid +handleAnimalProducer(new Producer(new Cat())); // Valid + +handleAnimalProducer(new Producer(new Car())); // Invalid +// Throws: TypeError: Argument $producer must be of type Producer|Producer +``` + +### Generic Containers Holding Intersections (`Collection`) + +Enforce that generic items must implement multiple interfaces simultaneously: + +```php +/** @var Collection $collections */ +$collections = new Collection(); + +$collections->add(new CountableArrayAccess()); // Valid (Implements both) + +$collections->add(new CountableOnly()); // Invalid (Fails ArrayAccess interface) +// Throws: TypeError: Argument $item must be of type Countable&ArrayAccess +``` + +### Complex Unions of Intersections in Generics + +You can combine parenthesized unions and intersections inside generic parameters: + +```php +/** @var Collection<(Countable&ArrayAccess)|(Iterator&Countable)> $payload */ +$payload = new Collection(); + +$payload->add(new CountableArrayAccess()); // Valid +$payload->add(new ArrayIterator([1, 2])); // Valid +``` + +--- + +## Deeply Nested Generics (`Collection>`) + +TypePHP recursively evaluates deeply nested generic structures down to any depth: + +```php +/** @var Collection> $producers */ +$producers = new Collection(); + +// Valid Addition +$producers->add(new Producer(new Dog())); + +// Invalid Addition (Producer holding Car instead of Dog) +$producers->add(new Producer(new Car())); +// Throws: TypeError: Argument $item must be an instance of Producer +``` diff --git a/docs/generics/basics-and-bounds.md b/docs/generics/basics-and-bounds.md new file mode 100644 index 0000000..29bedb6 --- /dev/null +++ b/docs/generics/basics-and-bounds.md @@ -0,0 +1,286 @@ +# Generics Basics & Bounds + +Generics parameterize classes, interfaces, and functions, allowing you to define reusable containers and algorithms that strictly enforce specific types at runtime. + +--- + +## What Are Generics and Why Do You Need Them? + +Without generics, a container class (like a `Collection` or `List`) or a wrapper service (like a `Repository` or `Response`) can only accept or return un-typed `mixed` or generic `object`: + +```php +// Without Generics: +$users = new Collection(); +$users->add(new User('Alice')); +$users->add(new Product('SKU-100')); // Accidental bug! Collection holds mixed types. +``` + +To prevent bugs without generics, you would have to write repetitive runtime type checks (`if (!$item instanceof User) throw ...`) inside every loop or method body. + +**Generics solve this problem.** Generics allow you to pass a type parameter (like ``) into a class or function signature. It parameterizes the container, telling PHP: *"This specific Collection instance holds ONLY User objects."* + +```php +// With Generics: +/** @var Collection $users */ +$users = new Collection(); +$users->add(new User('Alice')); // Valid +$users->add(new Product('SKU-100')); // TypePHP blocks this instantly at runtime! +``` + +--- + +## How Runtime Generics Work + +TypePHP manages generic templates at two distinct execution levels: + +1. **Function-Level Templates:** Bound per call site (e.g., `collectSameType(...$items)`). +2. **Class-Level Templates:** Bound to specific object instances in memory using PHP's native `WeakMap` (e.g., `Collection`). + +--- + +## PHP Limitation: No Native `instanceof` on Generics + +In PHP, writing generic syntax directly in executable statements (such as `if ($obj instanceof Collection)`) is a syntax error. + +In TypePHP, you declare generics in standard PHPDoc annotations (`@param Collection $users` or `/** @var Collection $users */`). TypePHP's AST engine intercepts these annotations and enforces generic contracts at runtime without altering native PHP syntax rules. + +--- + +## Basic Template Annotations (`@template T`) + +When a function uses `@template T` across multiple parameters, TypePHP infers `T` from the first argument and enforces consistency across all subsequent arguments: + +```php + + */ +function collectSameType(mixed ...$items): array +{ + return $items; +} + +// Valid Call (Infers T = int) +collectSameType(10, 20, 30); + +// Invalid Call (Infers T = int from item #1, then item #3 'invalid' fails T = int) +collectSameType(10, 20, 'invalid'); +// Throws: TypeError: collectSameType(): Argument $items[2] (template T = int) must be of type int +``` + +--- + +## Multiple Generic Templates (`@template T`, `@template U`) + +Functions and classes are not limited to a single template parameter. You can declare multiple independent generic templates (such as `T`, `U`, `K`, `V`): + +```php +/** + * @template T of Animal + * @template U of Car + * + * @param T $animal + * @param U $car + */ +function pairUp(Animal $animal, Car $car): void +{ + // T is bound to Animal subtype, U is bound to Car subtype +} + +// Valid Call (T = Dog, U = SportsCar) +pairUp(new Dog(), new SportsCar()); + +// Invalid Call (Swapped arguments: T receives Car, U receives Dog) +pairUp(new Car(), new Dog()); +// Throws: TypeError: pairUp(): Argument $animal (template T) must be an instance of Animal, Car given +``` + +--- + +## Generic Template Upper Bounds (`@template T of Bound`) + +Use `@template T of Bound` to restrict template arguments to a specific class hierarchy, interface, or scalar range: + +```php +abstract class Animal {} +class Dog extends Animal {} +class Car {} // Not an Animal! + +/** + * @template T of Animal + * + * @param T $animal + * @return T + */ +function processAnimal(Animal $animal): Animal +{ + return $animal; +} + +// Valid Call (Dog extends Animal) +processAnimal(new Dog()); + +// Invalid Call (Car does not extend Animal) +processAnimal(new Car()); +// Throws: TypeError: processAnimal(): Argument $animal (template T) must be an instance of Animal, Car given +``` + +--- + +## Default Generic Templates (`@template T = DefaultType`) + +If a template parameter `T` cannot be inferred from function arguments, TypePHP uses the declared default type: + +```php +/** + * @template T = string + * + * @param mixed $value + * @return T + */ +function getDefaultValue(mixed $value): mixed +{ + return $value; +} + +// Valid Call (Unbound T falls back to default: string) +getDefaultValue('valid_string'); + +// Invalid Call (Return value violates default string type) +getDefaultValue(12345); +// Throws: TypeError: getDefaultValue(): Return value must be of type string +``` + +--- + +## Upper Bounds with Defaults (`@template T of Bound = DefaultType`) + +Combine upper bounds with defaults (`@template T of Bound = DefaultType`) to enforce class inheritance while providing a fallback type: + +```php +/** + * @template T of object = stdClass + * + * @param mixed $value + * @return T + */ +function getObjectInstance(mixed $value): mixed +{ + return $value; +} + +// Valid Call (stdClass satisfies default stdClass) +getObjectInstance(new stdClass()); + +// Invalid Call (Dog is an object, but violates default stdClass) +getObjectInstance(new Dog()); +// Throws: TypeError: getObjectInstance(): Return value must be an instance of stdClass +``` + +--- + +## Generics of Scalars, Refinements, and Array Shapes + +Generic parameters (`T`) in TypePHP are not limited to object classes. You can bind generics to refined scalar types (`positive-int`, `non-empty-string`) or complex array shapes (`array{id: positive-int}`): + +### Generic Collections of Refined Scalars (`Collection`) + +```php +/** @var Collection $scores */ +$scores = new Collection(); + +$scores->add(100); // Valid + +$scores->add(-50); +// Throws: TypeError: Collection::add(): Argument $item (template T = positive-int) must be of type positive-int +``` + +### Generic Collections of Array Shapes (`Collection`) + +```php +/** @var Collection $userShapes */ +$userShapes = new Collection(); + +$userShapes->add(['id' => 1, 'name' => 'Alice']); // Valid + +$userShapes->add(['id' => -5, 'name' => 'Alice']); +// Throws: TypeError: Collection::add(): Argument $item['id'] must be of type positive-int +``` + +--- + +## First-Use Type Inference (Unannotated Generic Instances) + +If you instantiate a generic class without an inline `@var` prebinding annotation: + +```php +$collection = new Collection(); // No @var Collection annotation! +``` + +TypePHP automatically infers the template parameter `T` from the **first method call** executed on that object instance and locks `T` to that type in `WeakMap` memory for all subsequent calls: + +```php +// 1. First method call infers T = User and locks T to User for this instance! +$collection->add(new User('Alice')); + +// 2. Subsequent call succeeds because argument is a User +$collection->add(new User('Bob')); + +// 3. Subsequent call fails because T was locked to User on first use! +$collection->add(new Product('SKU-100')); +// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product given +``` + +--- + +## Simultaneous First-Use Multi-Template Inference + +If you instantiate a multi-template class without an inline `@var` annotation: + +```php +/** + * @template K of array-key = string + * @template V = int + */ +class MultiTemplateBag +{ + private array $storage = []; + + /** + * @param K $key + * @param V $val + */ + public function set(mixed $key, mixed $val): void + { + $this->storage[$key] = $val; + } +} +``` + +1. **Simultaneous Inference:** The very first method call (e.g. `$bag->set('timeout', 30)`) infers and locks **all active template parameters simultaneously** (`K = string`, `V = int`) in `WeakMap` memory. +2. **Lock-In:** All subsequent method calls on that instance enforce both locked types: + +```php +$bag = new MultiTemplateBag(); + +// 1. First method call infers K = string and V = int simultaneously +$bag->set('max_retries', 5); + +// 2. Subsequent call matching K = string and V = int succeeds +$bag->set('timeout', 30); // Valid + +// 3. Subsequent call violating locked K = string fails +$bag->set(12345, 30); +// Throws: TypeError: MultiTemplateBag::set(): Argument $key (template K = string) must be of type string + +// 4. Subsequent call violating locked V = int fails +$bag->set('timeout', 'thirty'); +// Throws: TypeError: MultiTemplateBag::set(): Argument $val (template V = int) must be of type int +``` + diff --git a/docs/generics/generics-and-bounds.md b/docs/generics/generics-and-bounds.md deleted file mode 100644 index 02f04d8..0000000 --- a/docs/generics/generics-and-bounds.md +++ /dev/null @@ -1,1099 +0,0 @@ -# Generics and Bounds - -## What Are Generics and Why Do You Need Them? - -Without generics, a container class (like a `Collection` or `List`) or a wrapper class (like a `Repository` or `Response`) can only accept or return `mixed` or `object`: - -```php -// Without Generics: -$users = new Collection(); -$users->add(new User('Alice')); -$users->add(new Product('SKU-100')); // Accidental bug! Collection holds mixed types. -``` - -To prevent bugs without generics, you would have to write repetitive runtime type checks (`if (!$item instanceof User) throw ...`) inside every loop or method. - -**Generics solve this problem.** Generics allow you to pass a type parameter (like ``) into a class or function signature. It parameterizes the container, telling PHP: *"This specific Collection instance holds ONLY User objects."* - -```php -// With Generics: -/** @var Collection $users */ -$users = new Collection(); -$users->add(new User('Alice')); // Valid -$users->add(new Product('SKU-100')); // TypePHP blocks this instantly at runtime! -``` - -PHP natively does not possess built-in generic syntax in executable statements (unlike languages like C# or Java). Modern PHP relies on PHPDoc annotations (`@template T`). TypePHP reads those PHPDoc annotations and actively enforces them during actual runtime execution! - ---- - -## How Runtime Generics Work - -TypePHP manages generic templates at two distinct execution levels: - -1. **Function-Level Templates:** Bound per call site (e.g., `collectSameType(...$items)`). -2. **Class-Level Templates:** Bound to specific object instances in memory using PHP's native `WeakMap` (e.g., `Collection`). - ---- - -## PHP Limitation: No Native `instanceof` on Generics - -In PHP, writing generic syntax directly in executable statements (such as `if ($obj instanceof Collection)`) is a PHP syntax error. - -In TypePHP, you declare generics exclusively in PHPDoc annotations (`@param Collection $users` or `/** @var Collection $users */`). TypePHP's AST engine intercepts these annotations and enforces generic contracts at runtime without altering native PHP syntax rules. - ---- - -## Basic Template Annotations (`@template T`) - -When a function uses `@template T` across multiple parameters, TypePHP infers `T` from the first argument and enforces consistency across all subsequent arguments: - -```php - - */ -function collectSameType(mixed ...$items): array -{ - return $items; -} - -// Valid Call (Infers T = int) -collectSameType(10, 20, 30); - -// Invalid Call (Infers T = int from item #1, then item #3 'invalid' fails T = int) -collectSameType(10, 20, 'invalid'); -// Throws: TypeError: collectSameType(): Argument $items[2] (template T = int) must be of type int -``` - ---- - -## Tooling Template Priority Hierarchy (`@phpstan-template-*` > `@psalm-template-*` > `@template-*`) - -When third-party packages or framework classes declare both general IDE template docblocks and strict tool-specific annotations on the same class, TypePHP resolves the active generic contract using a deterministic **3-Tier Priority Hierarchy**: - -$$\begin{aligned} -\mathbf{Priority\ 1\ (Highest):} & \quad \text{@phpstan-template-covariant} \ > \ \text{@phpstan-template-contravariant} \ > \ \text{@phpstan-template} \\ -\mathbf{Priority\ 2:} & \quad \text{@psalm-template-covariant} \ > \ \text{@psalm-template-contravariant} \ > \ \text{@psalm-template} \\ -\mathbf{Priority\ 3\ (Base):} & \quad \text{@template-covariant} \ > \ \text{@template-contravariant} \ > \ \text{@template} -\end{aligned}$$ - -### Why Priority Matters for Templates - -Authors often write a broad `@template T` for generic IDE docblocks, and then declare `@phpstan-template T of Animal` to specify strict upper bounds for static analyzers. TypePHP always extracts the tool-specific annotation so that runtime bound enforcement matches the author's intended contract: - -```php -/** - * Standard tag has no bound, but @phpstan-template enforces Animal bound: - * - * @template T - * @phpstan-template T of Animal - * @phpstan-template-covariant T - */ -class BoundedProducer -{ - public function __construct(public mixed $item) {} -} - -// Valid: Dog extends Animal -new BoundedProducer(new Dog()); - -// Invalid: Car does not extend Animal -new BoundedProducer(new Car()); -// Throws: TypeError: BoundedProducer::__construct(): Argument $item (template T) must be of type Animal, Car given -``` - ---- - -## Multiple Generic Templates (`@template T`, `@template U`) - -Functions and classes are not limited to a single template parameter. You can declare multiple independent generic templates (such as `T`, `U`, `K`, `V`): - -```php -/** - * @template T of Animal - * @template U of Car - * - * @param T $animal - * @param U $car - */ -function pairUp(Animal $animal, Car $car): void -{ - // T is bound to Animal subtype, U is bound to Car subtype -} - -// Valid Call (T = Dog, U = SportsCar) -pairUp(new Dog(), new SportsCar()); - -// Invalid Call (Swapped arguments: T receives Car, U receives Dog) -pairUp(new Car(), new Dog()); -// Throws: TypeError: pairUp(): Argument $animal (template T) must be an instance of Animal, Car given -``` - ---- - -## Simultaneous First-Use Multi-Template Inference - -If you instantiate a multi-template class without an inline `@var` annotation (e.g. `$bag = new MultiTemplateBag()`): - -```php -/** - * @template K of array-key = string - * @template V = int - */ -class MultiTemplateBag -{ - private array $storage = []; - - /** - * @param K $key - * @param V $val - */ - public function set(mixed $key, mixed $val): void - { - $this->storage[$key] = $val; - } -} -``` - -1. **Simultaneous Inference:** The very first method call (e.g. `$bag->set('timeout', 30)`) infers and locks **all active template parameters simultaneously** (`K = string`, `V = int`) in `WeakMap` memory. -2. **Lock-In:** All subsequent method calls on that instance enforce both locked types: - -```php -$bag = new MultiTemplateBag(); - -// 1. First method call infers K = string and V = int simultaneously -$bag->set('max_retries', 5); - -// 2. Subsequent call matching K = string and V = int succeeds -$bag->set('timeout', 30); // Valid - -// 3. Subsequent call violating locked K = string fails -$bag->set(12345, 30); -// Throws: TypeError: MultiTemplateBag::set(): Argument $key (template K = string) must be of type string - -// 4. Subsequent call violating locked V = int fails -$bag->set('timeout', 'thirty'); -// Throws: TypeError: MultiTemplateBag::set(): Argument $val (template V = int) must be of type int -``` - ---- - -## Reified Generics API (Kind of) - -Unlike languages that use Type Erasure (such as TypeScript or Java), TypePHP maintains generic template parameters in memory. - -You can inspect an object's bound generic types at runtime using `TypePHP::getGenericType()` or `TypePHP::getGenericTypes()`: - -```php -use TypePHP\TypePHP; - -/** @var Collection $users */ -$users = new Collection(); - -/** @var Dictionary $catalog */ -$catalog = new Dictionary(); - -// Single-Template Smart Fallback (No template name needed!) -$userType = TypePHP::getGenericType(object: $users); // Returns 'App\Models\User' - -// Multi-Template Explicit Inspection -$keyType = TypePHP::getGenericType(object: $catalog, template: 'K'); // Returns 'string' -$valueType = TypePHP::getGenericType(object: $catalog, template: 'V'); // Returns 'App\Models\Product' - -// Inherited Generic Classes (@extends BaseRepository) -$userRepo = new UserRepository(); -$repoType = TypePHP::getGenericType(object: $userRepo); // Returns 'App\Models\User' - -// Inspect all bound template parameters as an array -$types = TypePHP::getGenericTypes(object: $catalog); // Returns ['K' => 'string', 'V' => 'App\Models\Product'] - -// Inspect Declared Variance ('covariant', 'contravariant', or 'invariant') -$variance = TypePHP::getGenericVariance(object: $producer); // Returns 'covariant' - -// Inspect All Bound Variances as Arrays -$variances = TypePHP::getGenericVariances(object: $producer); // Returns ['T' => 'covariant'] -``` - -### How Reified Generic Inspection Works - -* **Single-Template Smart Fallback:** If a class has only 1 template parameter (e.g. `@template ItemType`), `TypePHP::getGenericType($object)` automatically returns that template's bound type without requiring you to guess whether the author named it `T`, `E`, or `ItemType`. -* **Inherited Template Resolution:** Automatically resolves generic types declared on parent classes (`@extends BaseRepository`) or interfaces (`@implements ProcessorInterface`). -* **First-Use Inference:** On un-annotated generic instances (`$collection = new Collection()`), `getGenericType()` returns `null` before first use, and returns the inferred type (e.g. `User`) immediately after the first method call! - ---- - -## Cloning Generic Instances (`clone $obj` and `__clone()`) - -When you clone an object instance that has bound generic templates (`$cloned = clone $original`), TypePHP automatically preserves and copies all bound generic template parameters (`T`) to the new cloned object instance in `WeakMap` memory: - -```php -/** @var Collection $users */ -$users = new Collection(); -$users->add(new User('Alice')); - -// Clone the generic collection -$clonedUsers = clone $users; - -// The cloned collection retains T = User! -$clonedUsers->add(new User('Bob')); // Valid - -$clonedUsers->add(new Product('SKU-100')); -// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User -``` - -### Explicit `__clone()` Magic Methods - -If a generic class defines an explicit `__clone()` magic method, TypePHP copies the generic template bindings to the new object instance **before** the `__clone()` method body executes. - -This ensures that any property assignments or method calls inside your `__clone()` implementation are immediately protected by the bound generic types: - -```php -/** - * @template T - */ -class GenericBox -{ - /** @var T */ - public mixed $item = null; - - /** @param T $item */ - public function set(mixed $item): void - { - $this->item = $item; - } - - public function __clone(): void - { - // TypePHP pre-copies T = Dog before __clone() runs! - $this->item = new Dog(); // Valid (Dog satisfies T = Dog) - } -} - -/** @var GenericBox $box */ -$box = new GenericBox(); -$clonedBox = clone $box; - -$clonedBox->set(new Car()); -// Throws: TypeError: GenericBox::set(): Argument $item (template T = Dog) must be of type Dog -``` - -### Cloned Instance Memory Isolation (`WeakMap`) - -When an object is cloned, its generic bindings are copied by value to the new instance. Because TypePHP uses `\WeakMap` keyed by object instance ID, **the original and cloned instances are 100% isolated in memory**. - -Re-binding or modifying the generic type on a cloned instance will never affect the original instance: - -```php -/** @var GenericBox $box1 */ -$box1 = new GenericBox(); - -// Clone $box1 into $box2 -$box2 = clone $box1; - -// Re-bind $box2 instance to GenericBox -/** @var GenericBox $box2 */ - -// $box2 now accepts Cat -$box2->set(new Cat()); // Valid for $box2 - -// $box1 continues enforcing T = Dog and rejects Cat! -$box1->set(new Cat()); -// Throws: TypeError: GenericBox::set(): Argument $item (template T = Dog) must be of type Dog -``` - -### Variance Enforcement on Cloned Assignments - -When you assign a cloned generic instance to a variable with an inline `@var` annotation, TypePHP enforces generic variance rules during the assignment. - -Because generics are **invariant** by default, assigning a cloned `GenericBox` instance into a variable annotated as `GenericBox` throws an invariant type mismatch `TypeError`: - -```php -/** @var GenericBox $box1 */ -$box1 = new GenericBox(); - -// Assigning a GenericBox clone into a GenericBox variable -/** @var GenericBox $box2 */ -$box2 = clone $box1; -// Throws: TypeError: Variable $box2 expects GenericBox, but GenericBox was given -``` - -> **Deep Dive Guide:** For complete details on how covariance, contravariance, and invariance rules work across generic containers, see the [Demystifying Variance](#demystifying-variance-covariant-contravariant-invariant) section below. - ---- - -## Generic Callables with Template Substitution - -When a function accepts a generic callback (`@param callable(T): T $transformer`), TypePHP automatically substitutes `T` with the inferred concrete type before callback invocation: - -```php -/** - * @template T - * - * @param callable(T): T $transformer - * @param T $input - * - * @return T - */ -function transformValue(callable $transformer, mixed $input): mixed -{ - return $transformer($input); -} - -// 1. Valid Call: Infers T = int, validates callback argument (int) and return (int) -$double = fn (int $x): int => $x * 2; -transformValue($double, 21); // Returns 42 - -// 2. Invalid Callback Return: T is inferred as int (from 10), but callback returns string ('invalid') -$badReturn = fn (int $x): string => 'invalid'; -transformValue($badReturn, 10); -// Throws: TypeError: transformValue(): Return value must be of type int, string 'invalid' returned -``` - ---- - -## Generic Iterables & Generators (`iterable` & `Generator`) - -TypePHP substitutes template parameters into iterators, validating yielded items, keys, and generator inputs (`$gen->send()`) lazily during execution: - -```php -/** - * @template T - * - * @param iterable $stream - * @param T $sample - * - * @return list - */ -function collectStream(iterable $stream, mixed $sample): array -{ - $collected = []; - foreach ($stream as $item) { - $collected[] = $item; - } - return $collected; -} - -// Infers T = int from $sample (1) -$iterator = new ArrayIterator([10, 'invalid', 30]); -collectStream($iterator, 1); -// Throws: TypeError: Iterator $stream value must be of type int, string 'invalid' given -``` - ---- - -## Conditional Types with Generics (`(T is Dog ? A : B)`) - -TypePHP dynamically evaluates conditional return types based on generic templates: - -```php -/** - * @template T - * - * @param T $input - * @param mixed $output - * - * @return (T is Dog ? positive-int : non-empty-string) - */ -function processInput(mixed $input, mixed $output): mixed -{ - return $output; -} - -// 1. T is inferred as Dog -> Evaluates return contract as positive-int -processInput(new Dog(), 100); // Valid - -// 2. T is inferred as Cat -> Evaluates return contract as non-empty-string -processInput(new Cat(), 'valid_string'); // Valid - -processInput(new Cat(), ''); // Invalid: empty string violates non-empty-string -// Throws: TypeError: processInput(): Return value must be of type non-empty-string -``` - -### Negated Generic Conditionals (`(T is not Dog ? A : B)`) - -```php -/** - * @template T - * - * @param T $input - * @param mixed $result - * - * @return (T is not Dog ? non-empty-string : positive-int) - */ -function processNegated(mixed $input, mixed $result): mixed -{ - return $result; -} - -processNegated(new Cat(), 'valid_text'); // Valid (Cat is not Dog -> non-empty-string) -processNegated(new Dog(), 42); // Valid (Dog is Dog -> positive-int) -``` - ---- - -## Generics of Scalars, Refinements, and Array Shapes, Etc.. - -Generic parameters (`T`) in TypePHP are not limited to object classes. You can bind generics to refined scalar types (`positive-int`, `non-empty-string`) or complex array shapes (`array{id: positive-int}`): - -### Generic Collections of Refined Scalars (`Collection`) - -```php -/** @var Collection $scores */ -$scores = new Collection(); - -$scores->add(100); // Valid - -$scores->add(-50); -// Throws: TypeError: Collection::add(): Argument $item (template T = positive-int) must be of type positive-int -``` - -### Generic Collections of Array Shapes (`Collection`) - -```php -/** @var Collection $userShapes */ -$userShapes = new Collection(); - -$userShapes->add(['id' => 1, 'name' => 'Alice']); // Valid - -$userShapes->add(['id' => -5, 'name' => 'Alice']); -// Throws: TypeError: Collection::add(): Argument $item['id'] must be of type positive-int -``` - -For full reference guides on all supported scalar refinements and array shape structures, see [Primitives & Scalars](/supported-types/primitives-and-scalars) and [Arrays & Shapes](/supported-types/arrays-and-shapes). - ---- - -## First-Use Type Inference (Unannotated Generic Instances) - -If you instantiate a generic class without an inline `@var` prebinding annotation: - -```php -$collection = new Collection(); // No @var Collection annotation! -``` - -TypePHP automatically infers the template parameter `T` from the **first method call** executed on that object instance and locks `T` to that type in `WeakMap` memory for all subsequent calls: - -```php -// 1. First method call infers T = User and locks T to User for this instance! -$collection->add(new User('Alice')); - -// 2. Subsequent call succeeds because argument is a User -$collection->add(new User('Bob')); - -// 3. Subsequent call fails because T was locked to User on first use! -$collection->add(new Product('SKU-100')); -// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product given -``` - ---- - -## Generics with Unions and Intersections - -TypePHP fully supports combining generic structures with Union (`|`) and Intersection (`&`) types: - -> **Deep Dive Guide:** For complete syntax rules, parenthesized intersections, and disjunctive normal forms, see the dedicated [Unions, Intersections & Conditionals](/supported-types/unions-intersections-and-conditionals) guide. - -### Generic Containers Holding Unions (`Collection`) - -Allow a generic container to hold multiple types specified in a union: - -```php -/** @var Collection $animals */ -$animals = new Collection(); - -$animals->add(new Dog()); // Valid -$animals->add(new Cat()); // Valid - -$animals->add(new Car()); // Invalid: Car is neither Dog nor Cat -// Throws: TypeError: Collection::add(): Argument $item (template T = Dog|Cat) must be of type (Dog | Cat) -``` - -### Unions of Generic Containers (`Producer | Producer`) - -Accept a union of separate generic container instances: - -```php -/** - * @param Producer|Producer $producer - */ -function handleAnimalProducer(Producer $producer): void -{ - // ... -} - -handleAnimalProducer(new Producer(new Dog())); // Valid -handleAnimalProducer(new Producer(new Cat())); // Valid - -handleAnimalProducer(new Producer(new Car())); // Invalid -// Throws: TypeError: Argument $producer must be of type Producer|Producer -``` - -### Generic Containers Holding Intersections (`Collection`) - -Enforce that generic items must implement multiple interfaces simultaneously: - -```php -/** @var Collection $collections */ -$collections = new Collection(); - -$collections->add(new CountableArrayAccess()); // Valid (Implements both) - -$collections->add(new CountableOnly()); // Invalid (Fails ArrayAccess interface) -// Throws: TypeError: Argument $item must be of type Countable&ArrayAccess -``` - -### Complex Unions of Intersections in Generics - -You can combine parenthesized unions and intersections inside generic parameters: - -```php -/** @var Collection<(Countable&ArrayAccess)|(Iterator&Countable)> $payload */ -$payload = new Collection(); - -$payload->add(new CountableArrayAccess()); // Valid -$payload->add(new ArrayIterator([1, 2])); // Valid -``` - ---- - -## Default Generic Templates (`@template T = DefaultType`) - -If a template parameter `T` cannot be inferred from function arguments, TypePHP uses the declared default type: - -```php -/** - * @template T = string - * - * @param mixed $value - * @return T - */ -function getDefaultValue(mixed $value): mixed -{ - return $value; -} - -// Valid Call (Unbound T falls back to default: string) -getDefaultValue('valid_string'); - -// Invalid Call (Return value violates default string type) -getDefaultValue(12345); -// Throws: TypeError: getDefaultValue(): Return value must be of type string -``` - ---- - -## Upper Bounds with Defaults (`@template T of Bound = DefaultType`) - -Combine upper bounds with defaults (`@template T of Bound = DefaultType`) to enforce class inheritance while providing a fallback type: - -```php -/** - * @template T of object = stdClass - * - * @param mixed $value - * @return T - */ -function getObjectInstance(mixed $value): mixed -{ - return $value; -} - -// Valid Call (stdClass satisfies default stdClass) -getObjectInstance(new stdClass()); - -// Invalid Call (Dog is an object, but violates default stdClass) -getObjectInstance(new Dog()); -// Throws: TypeError: getObjectInstance(): Return value must be an instance of stdClass -``` - ---- - -## Generic Template Bounds (`@template T of Bound`) - -Use `@template T of Bound` to restrict template arguments to a specific class, interface, or scalar range: - -```php -abstract class Animal {} -class Dog extends Animal {} -class Car {} // Not an Animal! - -/** - * @template T of Animal - * - * @param T $animal - * @return T - */ -function processAnimal(Animal $animal): Animal -{ - return $animal; -} - -// Valid Call (Dog extends Animal) -processAnimal(new Dog()); - -// Invalid Call (Car does not extend Animal) -processAnimal(new Car()); -// Throws: TypeError: processAnimal(): Argument $animal (template T) must be an instance of Animal, Car given -``` - ---- - -## Nested Generics (`Collection>`) - -TypePHP recursively evaluates deeply nested generic structures: - -```php -/** @var Collection> $producers */ -$producers = new Collection(); - -// Valid Addition -$producers->add(new Producer(new Dog())); - -// Invalid Addition (Producer holding Car instead of Dog) -$producers->add(new Producer(new Car())); -// Throws: TypeError: Argument $item must be an instance of Producer -``` - ---- - -## Class, Interface, & Trait Inheritance (`@extends`, `@implements`, `@use`) - -When a child class extends a generic parent class, implements a generic interface, or uses a generic trait, declare the template mapping using any of the recognized inherited template annotations: - -| Inheritance Context | Supported Tag Variations | -| :--- | :--- | -| **Class Inheritance** | `@extends`, `@template-extends`, `@phpstan-extends`, `@psalm-extends` | -| **Interface Implementation** | `@implements`, `@template-implements`, `@phpstan-implements`, `@psalm-implements` | -| **Trait Usage** | `@use`, `@template-use`, `@phpstan-use` | - -### 1. Interface Implementation (`@implements` / `@template-implements`) - -```php -/** - * Generic Interface - * - * @template T - */ -interface ProcessorInterface -{ - /** - * @param T $item - * @return T - */ - public function process(mixed $item): mixed; -} - -/** - * Fulfills T = Cat via @template-implements - * - * @template-implements ProcessorInterface - */ -class CatProcessor implements ProcessorInterface -{ - public function process(mixed $item): mixed - { - return $item; - } -} - -$processor = new CatProcessor(); - -// Valid Call -$processor->process(new Cat()); - -// Invalid Call -$processor->process(new Dog()); -// Throws: TypeError: CatProcessor::process(): Argument $item (template T = Cat) must be of type Cat -``` - -### 2. Class Extension (`@extends` / `@template-extends`) - -```php -/** - * @template T - */ -abstract class BaseRepository -{ - /** - * @param T $entity - */ - public function save(mixed $entity): void - { - // ... - } -} - -/** - * Fulfills T = User via @template-extends - * - * @template-extends BaseRepository - */ -class UserRepository extends BaseRepository -{ -} - -$userRepo = new UserRepository(); - -// Valid Save -$userRepo->save(new User('Alice')); - -// Invalid Save -$userRepo->save(new Product('SKU-100')); -// Throws: TypeError: UserRepository::save(): Argument $entity (template T = User) must be of type User -``` - -### 3. Generic Traits (`@use` / `@template-use` / `@phpstan-use`) - -When a class uses a generic Trait, declare the template binding either at the **class level** or **directly above the inline `use Trait;` statement**: - -#### Generic Trait Definition (`ItemLoggerTrait.php`) - -```php -/** - * @template T - */ -trait ItemLoggerTrait -{ - /** - * @param T $item - */ - public function logItem(mixed $item): bool - { - return true; - } -} -``` - -#### Option A: Class-Level Trait Annotation (`@use` / `@template-use`) - -```php -/** - * Class docblock binds T = Dog for the trait - * - * @use ItemLoggerTrait - */ -class ClassLevelLogService -{ - use ItemLoggerTrait; -} - -$service = new ClassLevelLogService(); - -$service->logItem(new Dog()); // Valid - -$service->logItem(new Car()); -// Throws: TypeError: Argument $item (template T = Dog) must be of type Dog, Car given -``` - -#### Option B: Inline Statement Trait Annotation (`/** @use */ use Trait;`) - -```php -class InlineLogService -{ - /** - * Inline statement docblock binds T = Dog - * - * @use ItemLoggerTrait - */ - use ItemLoggerTrait; -} - -$service = new InlineLogService(); - -$service->logItem(new Dog()); // Valid - -$service->logItem(new Car()); -// Throws: TypeError: Argument $item (template T = Dog) must be of type Dog, Car given -``` - ---- - -## Real-World Example 1: Generic Collections (`Collection`) - -TypePHP binds template types to object instances using `\WeakMap`. Prebind a generic collection using an inline `@var` annotation: - -```php -namespace App\Collections; - -use App\Models\User; -use App\Models\Product; - -/** - * @template T - */ -class Collection -{ - /** @var array */ - private array $items = []; - - /** - * @param T $item - */ - public function add(mixed $item): static - { - $this->items[] = $item; - return $this; - } - - /** - * @return array - */ - public function toArray(): array - { - return $this->items; - } -} - -// Prebind T = User to the $users object instance in WeakMap memory -/** @var Collection $users */ -$users = new Collection(); - -// Valid Addition -$users->add(new User('Alice')); - -// Invalid Addition (Product is not a User) -$users->add(new Product('SKU-999')); -// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product given -``` - ---- - -## Real-World Example 2: Generic Repositories (`Repository`) - -When a class extends a generic parent class (`@extends BaseRepository` or `@template-extends BaseRepository`), TypePHP automatically resolves and inherits the parent's generic template bindings: - -```php -namespace App\Repositories; - -use App\Models\User; - -/** - * @template T - */ -abstract class BaseRepository -{ - /** - * @param T $entity - */ - public function save(mixed $entity): void - { - // ... - } -} - -/** - * Fulfills T = User via @template-extends - * - * @template-extends BaseRepository - */ -class UserRepository extends BaseRepository -{ -} - -$userRepo = new UserRepository(); - -// Valid Save -$userRepo->save(new User('Alice')); - -// Invalid Save -$userRepo->save(new Product('SKU-100')); -// Throws: TypeError: UserRepository::save(): Argument $entity (template T = User) must be of type User -``` - ---- - -## Real-World Example 3: `class-string` Factories - -Use `class-string` to bind template `T` from a class name string and enforce matching return types: - -```php -/** - * Generic Factory Function - * - * @template T of object - * - * @param class-string $class - * @return T - */ -function makeInstance(string $class): object -{ - return new $class(); -} - -// Valid Call (Returns Dog instance matching class-string) -$dog = makeInstance(Dog::class); -``` - ---- - -## Demystifying Variance (`covariant`, `contravariant`, `invariant`) - -Generic variance controls how subtype relationships between underlying types affect the generic container. If `Dog` is a subclass of `Animal`, how does `Producer` relate to `Producer`? - -### Inline Variance Syntax - -In addition to class-level declarations (`@template-covariant T` / `@template-contravariant T`), TypePHP supports declaring variance inline directly on function parameter and return type hints: - -```php -/** - * @param Repository $repo - * @param Consumer $consumer - * @return Producer - */ -function processContracts(Repository $repo, Consumer $consumer): Producer -{ - // ... -} -``` - ---- - -### 1. Invariance (Default / Read-Write) - -By default, generics in TypePHP (and PHPStan) are **invariant**. Invariance requires an **exact type match**. - -```php -/** @template T */ -class Box { public function __construct(public mixed $item) {} } - -/** @param Box $box */ -function checkBox(Box $box): void {} - -checkBox(new Box(new Animal())); // Valid -checkBox(new Box(new Dog())); // Invalid in invariant mode! -``` - -**Why?** If `checkBox` modifies `$box->item = new Cat()`, putting a `Cat` into a `Box` would corrupt the box! Invariance prevents this. - ---- - -### 2. Covariance (`@template-covariant T` / Producer Mindset) - -Covariance allows **subtypes** (`Dog` for `Animal`). Think of covariance as a **Producer / Read-Only** relationship. - -If a function only *reads* from a container producing `Animal`s, passing a container producing `Dog`s is completely safe because every `Dog` read out of the container is guaranteed to be an `Animal`! - -```php -/** - * @template-covariant T - */ -class Producer -{ - public function __construct(public mixed $item) {} -} - -/** - * Accepts Producer holding Animal or any subtype of Animal (Dog, Cat) - * - * @param Producer $producer - */ -function handleProducer(Producer $producer): mixed -{ - return $producer->item; -} - -// Valid (Dog is a subtype of Animal) -handleProducer(new Producer(new Dog())); - -// Invalid (Car is not an Animal) -handleProducer(new Producer(new Car())); -// Throws: TypeError: handleProducer() expects Producer, but Producer was given -``` - ---- - -### 3. Contravariance (`@template-contravariant T` / Consumer Mindset) - -Contravariance allows **supertypes** (`Animal` for `Dog`). Think of contravariance as a **Consumer / Write-Only** relationship. - -If a function needs a handler that consumes a `Dog`, giving it a handler that can consume any `Animal` is completely safe because an `Animal` handler can process any `Dog` given to it! - -```php -class Puppy extends Dog {} - -/** - * @template-contravariant T - */ -class Consumer -{ - /** - * @param callable(T): void $handler - */ - public function __construct(public mixed $handler) {} - - /** - * @param T $item - */ - public function consume(mixed $item): void - { - ($this->handler)($item); - } -} - -/** - * Accepts Consumer designed for Dog or any supertype of Dog (Animal) - * - * @param Consumer $consumer - */ -function processDogConsumer(Consumer $consumer, Dog $dog): void -{ - $consumer->consume($dog); -} - -// Valid: Animal handler can safely consume a Dog! -$animalHandler = fn (Animal $a) => null; -processDogConsumer(new Consumer($animalHandler), new Dog()); - -// Invalid: Puppy handler cannot handle any general Dog! -$puppyHandler = fn (Puppy $p) => null; -processDogConsumer(new Consumer($puppyHandler), new Dog()); -// Throws: TypeError: processDogConsumer() expects Consumer, but Consumer was given -``` - -### Variance Precedence Rules - -What happens if an inline type hint specifies `Consumer`, but the class definition declared `@template-contravariant T`? - -TypePHP resolves variance conflicts using **Usage-Site Precedence**: - -1. **Usage-Site Override:** If a function parameter or return type explicitly specifies an inline variance modifier (`covariant` or `contravariant`), **the usage-site modifier takes precedence**. -2. **Class-Level Fallback:** If the call site uses standard syntax (`Consumer`), TypePHP falls back to the class's declared `@template-covariant` or `@template-contravariant` rule. - -```php -/** - * Class declares Contravariant T (Default: Consumer / Supertypes) - * - * @template-contravariant T - */ -class Consumer -{ - public function __construct(public mixed $handler) {} -} - -/** - * Function parameter EXPLICITLY overrides with inline 'covariant Animal' - * - * @param Consumer $consumer - */ -function processCovariantConsumer(Consumer $consumer): mixed -{ - return $consumer->handler; -} - -// 1. Valid Call (Dog is a subtype of Animal) -// Class declared contravariant, BUT function parameter explicitly specified 'covariant'. -// Usage-site 'covariant' wins! -processCovariantConsumer(new Consumer(new Dog())); - -// 2. Invalid Call (Car is not an Animal) -processCovariantConsumer(new Consumer(new Car())); -// Throws: TypeError: processCovariantConsumer() expects Consumer, but Consumer was given -``` -``` \ No newline at end of file diff --git a/docs/generics/inheritance-and-traits.md b/docs/generics/inheritance-and-traits.md new file mode 100644 index 0000000..f854738 --- /dev/null +++ b/docs/generics/inheritance-and-traits.md @@ -0,0 +1,226 @@ +# Class, Interface & Trait Inheritance + +TypePHP resolves and inherits generic contracts across parent classes, interfaces, and traits, ensuring subtype implementations strictly adhere to parameterized type contracts at runtime. + +--- + +## Tooling Template Priority Hierarchy (`@phpstan-template-*` > `@psalm-template-*` > `@template-*`) + +| Priority Tier | Scope | Evaluation Order (Highest → Lowest) | +| :--- | :--- | :--- | +| **Tier 1** *(Highest)* | **PHPStan** | `@phpstan-template-covariant` → `@phpstan-template-contravariant` → `@phpstan-template` | +| **Tier 2** | **Psalm** | `@psalm-template-covariant` → `@psalm-template-contravariant` → `@psalm-template` | +| **Tier 3** *(Base)* | **Standard** | `@template-covariant` → `@template-contravariant` → `@template` | + +### Why Priority Matters for Templates + +Authors often write a broad `@template T` for generic IDE compatibility, and then declare `@phpstan-template T of Animal` to specify strict upper bounds for static analyzers. TypePHP always extracts the tool-specific annotation so that runtime bound enforcement matches the author's intended contract: + +```php +/** + * Standard tag has no bound, but @phpstan-template enforces Animal bound: + * + * @template T + * @phpstan-template T of Animal + * @phpstan-template-covariant T + */ +class BoundedProducer +{ + public function __construct(public mixed $item) {} +} + +// Valid: Dog extends Animal +new BoundedProducer(new Dog()); + +// Invalid: Car does not extend Animal +new BoundedProducer(new Car()); +// Throws: TypeError: BoundedProducer::__construct(): Argument $item (template T) must be of type Animal, Car given +``` + +--- + +## Inherited Template Annotations (`@extends`, `@implements`, `@use`) + +When extending generic parent classes, implementing generic interfaces, or using generic traits, TypePHP treats all of the following variations as 100% equivalent: + +| Inheritance Context | Supported Tag Variations | +| :--- | :--- | +| **Class Inheritance** | `@extends`, `@template-extends`, `@phpstan-extends`, `@psalm-extends` | +| **Interface Implementation** | `@implements`, `@template-implements`, `@phpstan-implements`, `@psalm-implements` | +| **Trait Usage** | `@use`, `@template-use`, `@phpstan-use` | + +--- + +## Interface Implementation (`@implements` / `@template-implements`) + +When a class implements a generic interface, declare the concrete template binding via `@implements` or `@template-implements`: + +```php +/** + * Generic Interface + * + * @template T + */ +interface ProcessorInterface +{ + /** + * @param T $item + * @return T + */ + public function process(mixed $item): mixed; +} + +/** + * Fulfills T = Cat via @template-implements + * + * @template-implements ProcessorInterface + */ +class CatProcessor implements ProcessorInterface +{ + // No DocBlock needed on method! Inherits T = Cat from interface contract. + public function process(mixed $item): mixed + { + return $item; + } +} + +$processor = new CatProcessor(); + +// Valid Call +$processor->process(new Cat()); + +// Invalid Call (Dog is not a Cat) +$processor->process(new Dog()); +// Throws: TypeError: CatProcessor::process(): Argument $item (template T = Cat) must be of type Cat +``` + +--- + +## Class Extension (`@extends` / `@template-extends`) + +When a child class extends a generic parent class (`@extends BaseRepository`), TypePHP resolves and inherits the parent's generic template bindings across the entire class hierarchy: + +```php +namespace App\Repositories; + +use App\Models\User; +use App\Models\Product; + +/** + * @template T + */ +abstract class BaseRepository +{ + /** + * @param T $entity + */ + public function save(mixed $entity): void + { + // ... + } +} + +/** + * Fulfills T = User via @template-extends + * + * @template-extends BaseRepository + */ +class UserRepository extends BaseRepository +{ +} + +$userRepo = new UserRepository(); + +// Valid Save +$userRepo->save(new User('Alice')); + +// Invalid Save (Product is not a User) +$userRepo->save(new Product('SKU-100')); +// Throws: TypeError: UserRepository::save(): Argument $entity (template T = User) must be of type User +``` + +--- + +## Generic Traits (`@use` / `@template-use` / `@phpstan-use`) + +TypePHP supports binding generic template parameters to traits. You can declare the binding either at the **class level** or **directly above the inline `use Trait;` statement**: + +### Generic Trait Definition (`ItemLoggerTrait.php`) + +```php +/** + * @template T + */ +trait ItemLoggerTrait +{ + /** + * @param T $item + */ + public function logItem(mixed $item): bool + { + return true; + } +} +``` + +### Class-Level Trait Annotation (`@use` / `@template-use`) + +```php +/** + * Class docblock binds T = Dog for the trait + * + * @use ItemLoggerTrait + */ +class ClassLevelLogService +{ + use ItemLoggerTrait; +} + +$service = new ClassLevelLogService(); + +$service->logItem(new Dog()); // Valid + +$service->logItem(new Car()); +// Throws: TypeError: Argument $item (template T = Dog) must be of type Dog, Car given +``` + +### Inline Statement Trait Annotation (`/** @use */ use Trait;`) + +```php +class InlineLogService +{ + /** + * Inline statement docblock binds T = Dog + * + * @use ItemLoggerTrait + */ + use ItemLoggerTrait; +} + +$service = new InlineLogService(); + +$service->logItem(new Dog()); // Valid + +$service->logItem(new Car()); +// Throws: TypeError: Argument $item (template T = Dog) must be of type Dog, Car given +``` + +### Compact Single-Line Trait Annotation + +```php +class CompactLogService +{ + /** @use ItemLoggerTrait */ + use ItemLoggerTrait; +} +``` + +--- + +## Vendor DocBlock Isolation for Inherited Generics + +Third-party vendor libraries sometimes contain loose, outdated, or buggy DocBlock annotations. If your application extends a third-party vendor class or uses a vendor trait, TypePHP protects your application via **Vendor Isolation**: + +* If an ancestor class or trait is located inside an `exclude` directory (such as `/vendor/`), TypePHP **ignores its inherited DocBlocks**. +* If your application class (in `src/`, included) defines `/** @use VendorTrait */`, TypePHP **binds and enforces your application contract**, while keeping vendor internals protected. + diff --git a/docs/generics/reified-generics.md b/docs/generics/reified-generics.md new file mode 100644 index 0000000..8b12d77 --- /dev/null +++ b/docs/generics/reified-generics.md @@ -0,0 +1,260 @@ +# Reified Generics & State Management + +Unlike languages that erase types at compile-time (like Java, TypeScript, or standard Python), TypePHP provides **Reified Generics**—preserving generic parameters in memory per object instance throughout the entire application lifecycle. + +--- + +## What Are Reified Generics? + +* **Type Erasure (Java / TypeScript):** Generic type parameters like `` are erased during compilation. At runtime, the container becomes an un-typed `Object` or raw JavaScript array. You cannot inspect an object's generic parameters in live memory. +* **Reified Generics (C# / TypePHP):** Generic type parameters are preserved in live memory. Each instance knows its own bound type (`T = User`), allowing both runtime boundary enforcement and runtime reflection inspection. + +``` + THE TYPEPHP WEAKMAP ARCHITECTURE + + Class Definition Inline Pre-Binding Object Instance in RAM (WeakMap) + ┌───────────────────────────┐ ┌───────────────────────────┐ ┌─────────────────────────────────┐ + │ class Collection { ... } │ ──►│ /** @var Collection │ ──►│ Object #142 ──► ['T' => 'User'] │ + └───────────────────────────┘ └───────────────────────────┘ └─────────────────────────────────┘ + (0 Base Classes / 0 Metas) (0 New Keywords) (0 Memory Leaks, Inspectable) +``` + +--- + +## The Public Inspection API + +You can inspect an object's bound generic types and declared variances at runtime using TypePHP's public static methods: + +```php +use TypePHP\TypePHP; +use App\Models\User; +use App\Models\Product; +use App\Collections\Collection; +use App\Collections\Dictionary; + +/** @var Collection $users */ +$users = new Collection(); + +/** @var Dictionary $catalog */ +$catalog = new Dictionary(); + +// 1. Single-Template Smart Fallback (No template name needed!) +$userType = TypePHP::getGenericType(object: $users); +// Returns: 'App\Models\User' + +// 2. Multi-Template Explicit Inspection +$keyType = TypePHP::getGenericType(object: $catalog, template: 'K'); // Returns: 'string' +$valueType = TypePHP::getGenericType(object: $catalog, template: 'V'); // Returns: 'App\Models\Product' + +// 3. Inspect All Bound Template Parameters as an Array +$types = TypePHP::getGenericTypes(object: $catalog); +// Returns: ['K' => 'string', 'V' => 'App\Models\Product'] + +// 4. Inherited Generic Classes (@extends BaseRepository) +$userRepo = new UserRepository(); +$repoType = TypePHP::getGenericType(object: $userRepo); +// Returns: 'App\Models\User' + +// 5. Inspect Declared Variance ('covariant', 'contravariant', or 'invariant') +$variance = TypePHP::getGenericVariance(object: $producer); +// Returns: 'covariant' + +// 6. Inspect All Bound Variances as an Array +$variances = TypePHP::getGenericVariances(object: $producer); +// Returns: ['T' => 'covariant'] +``` + +### How Reified Generic Inspection Works + +* **Single-Template Smart Fallback:** If a class has only 1 template parameter (e.g. `@template ItemType`), `TypePHP::getGenericType($object)` automatically returns that template's bound type without requiring you to guess whether the author named it `T`, `E`, or `ItemType`. +* **Inherited Template Resolution:** Automatically resolves generic types declared on parent classes (`@extends BaseRepository`) or interfaces (`@implements ProcessorInterface`). +* **First-Use Inference:** On un-annotated generic instances (`$collection = new Collection()`), `getGenericType()` returns `null` before first use, and returns the inferred type (e.g. `User`) immediately after the first method call. + +--- + +## Memory Management via `\WeakMap` (Zero Memory Leaks) + +TypePHP manages generic state using PHP's native `\WeakMap`: + +1. **Weak References:** In `\WeakMap`, objects serve as keys. +2. **Automatic Garbage Collection:** The exact millisecond an object instance is unset or goes out of scope, PHP's garbage collector automatically deletes its generic type bindings from RAM. +3. **Zero Subclass Explosion:** TypePHP never creates dynamic subclasses or modifies class prototypes in memory. + +--- + +## Cloning Generic Instances (`clone $obj` and `__clone()`) + +When you clone an object instance that has bound generic templates (`$cloned = clone $original`), TypePHP automatically copies all bound generic parameters (`T`) to the new cloned instance in `WeakMap` memory: + +```php +/** @var Collection $users */ +$users = new Collection(); +$users->add(new User('Alice')); + +// Clone the generic collection +$clonedUsers = clone $users; + +// The cloned collection retains T = User! +$clonedUsers->add(new User('Bob')); // Valid + +$clonedUsers->add(new Product('SKU-100')); +// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User +``` + +### Explicit `__clone()` Magic Methods + +If a generic class defines an explicit `__clone()` magic method, TypePHP copies the generic template bindings to the new object instance **before** the `__clone()` method body executes. + +This ensures that any property assignments or method calls inside your `__clone()` implementation are immediately protected by the bound generic types: + +```php +/** + * @template T + */ +class GenericBox +{ + /** @var T */ + public mixed $item = null; + + /** @param T $item */ + public function set(mixed $item): void + { + $this->item = $item; + } + + public function __clone(): void + { + // TypePHP pre-copies T = Dog before __clone() runs! + $this->item = new Dog(); // Valid (Dog satisfies T = Dog) + } +} + +/** @var GenericBox $box */ +$box = new GenericBox(); +$clonedBox = clone $box; + +$clonedBox->set(new Car()); +// Throws: TypeError: GenericBox::set(): Argument $item (template T = Dog) must be of type Dog +``` + +### Cloned Instance Memory Isolation (`WeakMap`) + +When an object is cloned, its generic bindings are copied by value to the new instance. Because TypePHP uses `\WeakMap` keyed by object instance ID, **the original and cloned instances are 100% isolated in memory**. + +Re-binding or modifying the generic type on a cloned instance will never affect the original instance: + +```php +/** @var GenericBox $box1 */ +$box1 = new GenericBox(); + +// Clone $box1 into $box2 +$box2 = clone $box1; + +// Re-bind $box2 instance to GenericBox +/** @var GenericBox $box2 */ + +// $box2 now accepts Cat +$box2->set(new Cat()); // Valid for $box2 + +// $box1 continues enforcing T = Dog and rejects Cat! +$box1->set(new Cat()); +// Throws: TypeError: GenericBox::set(): Argument $item (template T = Dog) must be of type Dog +``` + +### Variance Enforcement on Cloned Assignments + +When you assign a cloned generic instance to a variable with an inline `@var` annotation, TypePHP enforces generic variance rules during the assignment. + +Because generics are **invariant** by default, assigning a cloned `GenericBox` instance into a variable annotated as `GenericBox` throws an invariant type mismatch `TypeError`: + +```php +/** @var GenericBox $box1 */ +$box1 = new GenericBox(); + +// Assigning a GenericBox clone into a GenericBox variable +/** @var GenericBox $box2 */ +$box2 = clone $box1; +// Throws: TypeError: Variable $box2 expects GenericBox, but GenericBox was given +``` + +--- + +## Real-World Example 1: Generic Collections (`Collection`) + +Prebind a generic collection using an inline `@var` annotation: + +```php +namespace App\Collections; + +use App\Models\User; +use App\Models\Product; + +/** + * @template T + */ +class Collection +{ + /** @var array */ + private array $items = []; + + /** + * @param T $item + */ + public function add(mixed $item): static + { + $this->items[] = $item; + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->items; + } +} + +// Prebind T = User to the $users object instance in WeakMap memory +/** @var Collection $users */ +$users = new Collection(); + +// Valid Addition +$users->add(new User('Alice')); + +// Invalid Addition (Product is not a User) +$users->add(new Product('SKU-999')); +// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product given +``` + +--- + +## Real-World Example 2: `class-string` Factories + +Use `class-string` to bind template `T` from a class name string and enforce matching return types: + +```php +abstract class Animal {} +class Dog extends Animal {} +class Car {} // Not an Animal! + +/** + * Generic Factory Function + * + * @template T of Animal + * + * @param class-string $class + * @return T + */ +function makeAnimal(string $class): Animal +{ + return new $class(); +} + +// Valid Call (Dog extends Animal, binds T = Dog) +$dog = makeAnimal(Dog::class); + +// Invalid Call (Car does not extend Animal) +makeAnimal(Car::class); +// Throws: TypeError: makeAnimal(): Argument $class (class-string) must be a class-string of Animal, 'Car' given +``` diff --git a/docs/generics/variance.md b/docs/generics/variance.md new file mode 100644 index 0000000..7dbd052 --- /dev/null +++ b/docs/generics/variance.md @@ -0,0 +1,216 @@ +# Demystifying Variance + +Generic variance controls how subtype relationships between underlying types affect the generic container. If `Dog` is a subclass of `Animal`, what is the relationship between `Producer` and `Producer`? + +--- + +## The Core Question of Variance + +* If **`Dog extends Animal`**, does **`Container` extend `Container`**? + +The answer depends on whether the container is **reading data (Producer)**, **writing data (Consumer)**, or **both (Read-Write)**: + +``` + THE 3 MODES OF GENERIC VARIANCE + + 1. Invariance (Default) 2. Covariance (Producer) 3. Contravariance (Consumer) + ┌─────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐ + │ Exact Match ONLY │ │ Subtypes Allowed (Dog) │ │ Supertypes Allowed │ + │ Read-Write Container │ │ Read-Only Container │ │ Write-Only Consumer │ + └─────────────────────────┘ └───────────────────────────┘ └───────────────────────────┘ +``` + +--- + +## 1. Invariance (Default / Read-Write Containers) + +By default, generics in TypePHP (and PHPStan) are **invariant**. Invariance requires an **exact type match**. + +```php +/** @template T */ +class Box +{ + public function __construct(public mixed $item) {} +} + +/** + * @param Box $box + */ +function checkBox(Box $box): void +{ + // ... +} + +checkBox(new Box(new Animal())); // Valid +checkBox(new Box(new Dog())); // Invalid in invariant mode! +// Throws: TypeError: Argument $box expects Box, but Box was given +``` + +### Why Invariance is Mandatory for Read-Write Containers +If PHP allowed `Box` to be passed into `checkBox(Box $box)`: +```php +function checkBox(Box $box): void +{ + $box->item = new Cat(); // Valid for Box, but corrupts Box! +} +``` +Putting a `Cat` into what the caller thought was a `Box` would corrupt memory state! **Invariance completely prevents this bug.** + +--- + +## 2. Covariance (`@template-covariant T` / Producer Mindset) + +Covariance allows **subtypes** (`Dog` for `Animal`). Think of covariance as a **Producer / Read-Only** relationship. + +If a function only *reads* from a container producing `Animal`s, passing a container producing `Dog`s is 100% safe because every `Dog` read out of the container is guaranteed to be an `Animal`! + +```php +/** + * @template-covariant T + */ +class Producer +{ + public function __construct(public mixed $item) {} +} + +/** + * Accepts Producer holding Animal or any subtype of Animal (Dog, Cat) + * + * @param Producer $producer + */ +function handleProducer(Producer $producer): mixed +{ + return $producer->item; +} + +// 1. Valid Call (Dog is a subtype of Animal) +handleProducer(new Producer(new Dog())); + +// 2. Valid Call (Cat is a subtype of Animal) +handleProducer(new Producer(new Cat())); + +// 3. Invalid Call (Car is not an Animal) +handleProducer(new Producer(new Car())); +// Throws: TypeError: handleProducer() expects Producer, but Producer was given +``` + +--- + +## 3. Contravariance (`@template-contravariant T` / Consumer Mindset) + +Contravariance allows **supertypes** (`Animal` for `Dog`). Think of contravariance as a **Consumer / Write-Only** relationship. + +If a function needs a handler that consumes a `Dog`, giving it a handler that can consume any general `Animal` is 100% safe because an `Animal` handler can process any `Dog` given to it! + +```php +class Puppy extends Dog {} + +/** + * @template-contravariant T + */ +class Consumer +{ + /** + * @param callable(T): void $handler + */ + public function __construct(public mixed $handler) {} + + /** + * @param T $item + */ + public function consume(mixed $item): void + { + ($this->handler)($item); + } +} + +/** + * Accepts Consumer designed for Dog or any supertype of Dog (Animal) + * + * @param Consumer $consumer + */ +function processDogConsumer(Consumer $consumer, Dog $dog): void +{ + $consumer->consume($dog); +} + +// 1. Valid: Animal handler can safely consume a Dog! +$animalHandler = fn (Animal $a) => null; +processDogConsumer(new Consumer($animalHandler), new Dog()); + +// 2. Invalid: Puppy handler cannot handle any general Dog! +$puppyHandler = fn (Puppy $p) => null; +processDogConsumer(new Consumer($puppyHandler), new Dog()); +// Throws: TypeError: processDogConsumer() expects Consumer, but Consumer was given +``` + +--- + +## Inline Usage-Site Variance Syntax + +In addition to class-level declarations (`@template-covariant T` / `@template-contravariant T`), TypePHP supports declaring variance inline directly on function parameter and return type hints: + +```php +/** + * @param Repository $repo + * @param Consumer $consumer + * @return Producer + */ +function processContracts(Repository $repo, Consumer $consumer): Producer +{ + // ... +} +``` + +--- + +## Variance Precedence Rules (Usage-Site Overrides) + +What happens if an inline type hint specifies `Consumer`, but the class definition declared `@template-contravariant T`? + +TypePHP resolves variance conflicts using **Usage-Site Precedence**: + +1. **Usage-Site Override:** If a function parameter or return type explicitly specifies an inline variance modifier (`covariant` or `contravariant`), **the usage-site modifier takes precedence**. +2. **Class-Level Fallback:** If the call site uses standard syntax (`Consumer`), TypePHP falls back to the class's declared `@template-covariant` or `@template-contravariant` rule. + +```php +/** + * Class declares Contravariant T (Default: Consumer / Supertypes) + * + * @template-contravariant T + */ +class Consumer +{ + public function __construct(public mixed $handler) {} +} + +/** + * Function parameter EXPLICITLY overrides with inline 'covariant Animal' + * + * @param Consumer $consumer + */ +function processCovariantConsumer(Consumer $consumer): mixed +{ + return $consumer->handler; +} + +// 1. Valid Call (Dog is a subtype of Animal) +// Class declared contravariant, BUT function parameter explicitly specified 'covariant'. +// Usage-site 'covariant' wins! +processCovariantConsumer(new Consumer(new Dog())); + +// 2. Invalid Call (Car is not an Animal) +processCovariantConsumer(new Consumer(new Car())); +// Throws: TypeError: processCovariantConsumer() expects Consumer, but Consumer was given +``` + +--- + +## Summary Matrix + +| Variance Mode | Keyword / Syntax | Allowed Types | Mental Model | +| :--- | :--- | :--- | :--- | +| **Invariant** (Default) | `Collection` | **Exact type only** | **Read-Write:** Prevents container corruption. | +| **Covariant** | `@template-covariant T`
`Box` | **Subtypes** (`Dog`, `Cat`) | **Producer:** Safe for reading data out. | +| **Contravariant** | `@template-contravariant T`
`Consumer` | **Supertypes** (`Animal`) | **Consumer:** Safe for writing data in. | + diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 9dd4af2..44d7588 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -4,6 +4,7 @@ namespace TypePHP\Contract; +use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; @@ -120,70 +121,23 @@ public static function parseProperty(string $className, string $propertyName): ? /** @var class-string $className */ $refClass = new \ReflectionClass($className); - $doc = false; - $declaringClass = null; + $resolved = self::findDeclaredPropertyDoc($refClass, $propertyName); + $doc = $resolved['doc'] ?? false; + $declaringClass = $resolved['declaringClass'] ?? null; $typeNode = null; $isMagicProperty = false; - $current = $refClass; - while ($current !== false) { - if ($current->hasProperty($propertyName)) { - $refProp = $current->getProperty($propertyName); - $fetchedDoc = $refProp->getDocComment(); - if ($fetchedDoc !== false) { - $doc = $fetchedDoc; - $declaringClass = $current; - - break; - } - } - $current = $current->getParentClass(); - } - - if ($doc === false) { - foreach ($refClass->getInterfaces() as $interface) { - if ($interface->hasProperty($propertyName)) { - $interfaceProp = $interface->getProperty($propertyName); - $fetchedDoc = $interfaceProp->getDocComment(); - if ($fetchedDoc !== false) { - $doc = $fetchedDoc; - $declaringClass = $interface; - - break; - } - } - } - } - if ($doc === false && (bool) (Config::get()['magic_properties'] ?? true)) { - $classHierarchy = HierarchyResolver::getClassHierarchy($refClass); - foreach ($classHierarchy as $hierClass) { - $fileName = $hierClass->getFileName(); - if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { - continue; - } - - $classDoc = $hierClass->getDocComment(); - if ($classDoc !== false) { - $extractedType = DocblockExtractor::extractTypeFromClassPropertyDoc($classDoc, $propertyName); - if ($extractedType !== null) { - $doc = $classDoc; - $declaringClass = $hierClass; - $typeNode = $extractedType; - $isMagicProperty = true; - - break; - } - } + $magicResolved = self::findMagicPropertyDoc($refClass, $propertyName); + if ($magicResolved !== null) { + $doc = $magicResolved['doc']; + $declaringClass = $magicResolved['declaringClass']; + $typeNode = $magicResolved['typeNode']; + $isMagicProperty = true; } } - if ($doc === false || $declaringClass === null) { - return self::$propertyCache[$cacheKey] = null; - } - - $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); - if ($shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'))) { + if ($doc === false || $declaringClass === null || self::shouldIgnoreDoc($doc)) { return self::$propertyCache[$cacheKey] = null; } @@ -220,6 +174,70 @@ public static function parseProperty(string $className, string $propertyName): ? } } + /** + * @param \ReflectionClass $refClass + * + * @return array{doc: string, declaringClass: \ReflectionClass}|null + */ + private static function findDeclaredPropertyDoc(\ReflectionClass $refClass, string $propertyName): ?array + { + $current = $refClass; + while ($current !== false) { + if ($current->hasProperty($propertyName)) { + $refProp = $current->getProperty($propertyName); + $doc = $refProp->getDocComment(); + if ($doc !== false) { + return ['doc' => $doc, 'declaringClass' => $current]; + } + } + $parent = $current->getParentClass(); + $current = $parent !== false ? $parent : false; + } + + foreach ($refClass->getInterfaces() as $interface) { + if ($interface->hasProperty($propertyName)) { + $interfaceProp = $interface->getProperty($propertyName); + $doc = $interfaceProp->getDocComment(); + if ($doc !== false) { + return ['doc' => $doc, 'declaringClass' => $interface]; + } + } + } + + return null; + } + + /** + * @param \ReflectionClass $refClass + * + * @return array{doc: string, declaringClass: \ReflectionClass, typeNode: TypeNode}|null + */ + private static function findMagicPropertyDoc(\ReflectionClass $refClass, string $propertyName): ?array + { + $classHierarchy = HierarchyResolver::getClassHierarchy($refClass); + + foreach ($classHierarchy as $hierClass) { + $fileName = $hierClass->getFileName(); + if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { + continue; + } + + $classDoc = $hierClass->getDocComment(); + if ($classDoc !== false) { + $extractedType = DocblockExtractor::extractTypeFromClassPropertyDoc($classDoc, $propertyName); + if ($extractedType !== null) { + return [ + 'doc' => $classDoc, + 'declaringClass' => $hierClass, + 'typeNode' => $extractedType, + ]; + } + } + } + + return null; + } + /** * Parses and resolves a class-level @method docblock for __call / __callStatic. * @@ -239,36 +257,17 @@ public static function parseMagicMethod(string $className, string $methodName): try { /** @var class-string $className */ $refClass = new \ReflectionClass($className); - $doc = false; - $declaringClass = null; - $methodTag = null; - - $classHierarchy = HierarchyResolver::getClassHierarchy($refClass); - foreach ($classHierarchy as $hierClass) { - $fileName = $hierClass->getFileName(); - if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { - continue; - } - - $classDoc = $hierClass->getDocComment(); - if ($classDoc !== false) { - $tag = DocblockExtractor::extractMagicMethodContract($classDoc, $methodName); - if ($tag !== null) { - $doc = $classDoc; - $declaringClass = $hierClass; - $methodTag = $tag; - - break; - } - } - } - if ($methodTag === null || $declaringClass === null || $doc === false) { + $resolved = self::findMagicMethodDoc($refClass, $methodName); + if ($resolved === null) { return self::$magicMethodCache[$cacheKey] = null; } - $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); - if ($shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'))) { + $doc = $resolved['doc']; + $declaringClass = $resolved['declaringClass']; + $methodTag = $resolved['methodTag']; + + if (self::shouldIgnoreDoc($doc)) { return self::$magicMethodCache[$cacheKey] = null; } @@ -285,43 +284,7 @@ public static function parseMagicMethod(string $className, string $methodName): $resolvedReturn = SpecialTypeResolver::resolve($subReturn, $declaringClass); } - $resolvedParams = []; - foreach ($methodTag->parameters as $p) { - $pType = $p->type ?? null; - if ($pType !== null) { - $subType = self::substituteAliases($pType, $aliases); - $pType = SpecialTypeResolver::resolve($subType, $declaringClass); - } - - $rawParamName = ''; - $pVars = get_object_vars($p); - foreach ($pVars as $key => $val) { - if (\is_string($val) && str_starts_with($val, '$')) { - $rawParamName = $val; - - break; - } - } - if ($rawParamName === '') { - foreach (['parameterName', 'name', 'paramName', 'varName'] as $key) { - if (isset($pVars[$key]) && \is_string($pVars[$key])) { - $rawParamName = $pVars[$key]; - - break; - } - } - } - - $pName = ltrim($rawParamName, '$'); - $isOptional = (isset($pVars['isOptional']) && (bool) $pVars['isOptional']) || (($p->defaultValue ?? null) !== null); - - $resolvedParams[] = [ - 'name' => $pName, - 'type' => $pType, - 'isVariadic' => $p->isVariadic, - 'isOptional' => $isOptional, - ]; - } + $resolvedParams = self::resolveMagicParameters($methodTag, $declaringClass, $aliases); return self::$magicMethodCache[$cacheKey] = [ 'return' => $resolvedReturn, @@ -334,6 +297,105 @@ public static function parseMagicMethod(string $className, string $methodName): } } + /** + * @param \ReflectionClass $refClass + * + * @return array{doc: string, declaringClass: \ReflectionClass, methodTag: MethodTagValueNode}|null + */ + private static function findMagicMethodDoc(\ReflectionClass $refClass, string $methodName): ?array + { + $classHierarchy = HierarchyResolver::getClassHierarchy($refClass); + + foreach ($classHierarchy as $hierClass) { + $fileName = $hierClass->getFileName(); + if ($hierClass !== $refClass && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { + continue; + } + + $classDoc = $hierClass->getDocComment(); + if ($classDoc !== false) { + $tag = DocblockExtractor::extractMagicMethodContract($classDoc, $methodName); + if ($tag !== null) { + return [ + 'doc' => $classDoc, + 'declaringClass' => $hierClass, + 'methodTag' => $tag, + ]; + } + } + } + + return null; + } + + /** + * @param \ReflectionClass $declaringClass + * @param array $aliases + * + * @return array + */ + private static function resolveMagicParameters( + MethodTagValueNode $methodTag, + \ReflectionClass $declaringClass, + array $aliases + ): array { + $resolvedParams = []; + + foreach ($methodTag->parameters as $p) { + $pType = $p->type ?? null; + if ($pType !== null) { + $subType = self::substituteAliases($pType, $aliases); + $pType = SpecialTypeResolver::resolve($subType, $declaringClass); + } + + $pName = self::extractRawParamName($p); + $pVars = get_object_vars($p); + $isOptional = (isset($pVars['isOptional']) && (bool) $pVars['isOptional']) || (($p->defaultValue ?? null) !== null); + + $resolvedParams[] = [ + 'name' => $pName, + 'type' => $pType, + 'isVariadic' => $p->isVariadic, + 'isOptional' => $isOptional, + ]; + } + + return $resolvedParams; + } + + private static function extractRawParamName(object $paramNode): string + { + $rawParamName = ''; + $pVars = get_object_vars($paramNode); + + foreach ($pVars as $key => $val) { + if (\is_string($val) && str_starts_with($val, '$')) { + $rawParamName = $val; + + break; + } + } + + if ($rawParamName === '') { + foreach (['parameterName', 'name', 'paramName', 'varName'] as $key) { + if (isset($pVars[$key]) && \is_string($pVars[$key])) { + $rawParamName = $pVars[$key]; + + break; + } + } + } + + return ltrim($rawParamName, '$'); + } + + private static function shouldIgnoreDoc(string $doc): bool + { + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); + + return $shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable')); + } + /** * Extracts and returns all class-level type aliases for a given class. * @@ -535,21 +597,7 @@ private static function parseMethodHierarchyDocs( $paramTags = DocblockExtractor::getParamTags($phpDocNode); foreach ($paramTags as $paramName => $paramTag) { - if (isset($baseParamSet[$paramName])) { - $targetParamName = $paramName; - } else { - $paramIndex = $hierNameToIndex[$paramName] ?? null; - if ($paramIndex !== null && isset($baseParamNames[$paramIndex])) { - $candidateName = $baseParamNames[$paramIndex]; - if (! isset($hierNameToIndex[$candidateName])) { - $targetParamName = $candidateName; - } else { - $targetParamName = null; - } - } else { - $targetParamName = null; - } - } + $targetParamName = self::resolveTargetParamName($paramName, $baseParamSet, $baseParamNames, $hierNameToIndex); if ($targetParamName !== null && ! isset($types[$targetParamName])) { $type = $paramTag->type; @@ -572,6 +620,33 @@ private static function parseMethodHierarchyDocs( } } + /** + * Disambiguates parameter name vs index position during inheritance. + * + * @param array $baseParamSet + * @param array $baseParamNames + * @param array $hierNameToIndex + */ + private static function resolveTargetParamName( + string $paramName, + array $baseParamSet, + array $baseParamNames, + array $hierNameToIndex + ): ?string { + if (isset($baseParamSet[$paramName])) { + return $paramName; + } + + $paramIndex = $hierNameToIndex[$paramName] ?? null; + if ($paramIndex === null || ! isset($baseParamNames[$paramIndex])) { + return null; + } + + $candidateName = $baseParamNames[$paramIndex]; + + return ! isset($hierNameToIndex[$candidateName]) ? $candidateName : null; + } + /** * Falls back to property @var docblocks for constructor promoted parameters if un-annotated. * diff --git a/src/Internal/Checker/GeneratorChecker.php b/src/Internal/Checker/GeneratorChecker.php index 6ddc7a0..e8ac63c 100644 --- a/src/Internal/Checker/GeneratorChecker.php +++ b/src/Internal/Checker/GeneratorChecker.php @@ -6,6 +6,8 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; use TypePHP\Contract\ContractParser; use TypePHP\Resolver\SpecialTypeResolver; use TypePHP\Resolver\TemplateManager; @@ -17,49 +19,83 @@ */ final class GeneratorChecker { - public static function checkSend(string $function, mixed $sendValue, TypeValidatorRegistry $registry, object|string|null $thisOrClass = null): mixed - { + /** + * Validates a value sent into a generator via $gen->send() against TSend. + */ + public static function checkSend( + string $function, + mixed $sendValue, + TypeValidatorRegistry $registry, + object|string|null $thisOrClass = null + ): mixed { if ($sendValue === null) { return null; } - $contract = ContractParser::parse($function); - $returnTypeNode = $contract['return'] ?? null; + $returnTypeNode = self::resolveGeneratorReturnType($function, $thisOrClass); + if (! ($returnTypeNode instanceof GenericTypeNode)) { + return $sendValue; + } - if ($returnTypeNode === null) { + $sendTypeNode = $returnTypeNode->genericTypes[2] ?? null; + if ($sendTypeNode === null) { return $sendValue; } - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $templates = $contract['templates'] ?? []; - $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + $err = $registry->validate($sendValue, $sendTypeNode, "$function(): Generator sent value (TSend)"); - if (\count($boundTemplates) > 0 || \count($templates) > 0) { - $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $templates); - $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); + return $err ?? $sendValue; + } + + /** + * Validates yielded keys and values from a generator function against TKey and TValue. + */ + public static function checkYield( + string $function, + mixed $key, + mixed $value, + TypeValidatorRegistry $registry, + object|string|null $thisOrClass = null + ): mixed { + $returnTypeNode = self::resolveGeneratorReturnType($function, $thisOrClass); + if ($returnTypeNode === null) { + return $value; } - if ($returnTypeNode instanceof GenericTypeNode) { - $sendTypeNode = $returnTypeNode->genericTypes[2] ?? null; + [$keyTypeNode, $itemTypeNode] = self::extractYieldTypes($returnTypeNode); - if ($sendTypeNode !== null) { - $err = $registry->validate($sendValue, $sendTypeNode, "$function(): Generator sent value (TSend)"); - if ($err !== null) { - return $err; - } + if ($key !== null && $keyTypeNode !== null) { + $err = $registry->validate($key, $keyTypeNode, "$function(): Return iterator key"); + if ($err !== null) { + return $err; } } - return $sendValue; + if ($itemTypeNode !== null) { + $err = $registry->validate($value, $itemTypeNode, "$function(): Return iterator value"); + if ($err !== null) { + return $err; + } + } + + return $value; } - public static function checkYield(string $function, mixed $key, mixed $value, TypeValidatorRegistry $registry, object|string|null $thisOrClass = null): mixed + /** + * Resolves the generator's return contract, applying alias expansion, template substitution, and special types. + */ + private static function resolveGeneratorReturnType(string $function, object|string|null $thisOrClass): ?TypeNode { $contract = ContractParser::parse($function); $returnTypeNode = $contract['return'] ?? null; if ($returnTypeNode === null) { - return $value; + return null; + } + + $aliases = $contract['aliases'] ?? []; + if ($returnTypeNode instanceof IdentifierTypeNode && isset($aliases[$returnTypeNode->name])) { + $returnTypeNode = $aliases[$returnTypeNode->name]; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; @@ -71,6 +107,16 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); } + return $returnTypeNode; + } + + /** + * Extracts yielded key and item TypeNodes from a resolved generator/array AST node. + * + * @return array{0: ?TypeNode, 1: ?TypeNode} + */ + private static function extractYieldTypes(TypeNode $returnTypeNode): array + { $itemTypeNode = null; $keyTypeNode = null; @@ -86,20 +132,6 @@ public static function checkYield(string $function, mixed $key, mixed $value, Ty $itemTypeNode = $returnTypeNode->type; } - if ($key !== null && $keyTypeNode !== null) { - $err = $registry->validate($key, $keyTypeNode, "$function(): Return iterator key"); - if ($err !== null) { - return $err; - } - } - - if ($itemTypeNode !== null) { - $err = $registry->validate($value, $itemTypeNode, "$function(): Return iterator value"); - if ($err !== null) { - return $err; - } - } - - return $value; + return [$keyTypeNode, $itemTypeNode]; } } diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index ac0a50a..7eb230c 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -40,6 +40,53 @@ final class InlineChecker */ private static array $parsedTypeNodeCache = []; + /** + * Fast lookup set for scalar refinement types. + */ + private const SCALAR_TYPES = [ + 'int' => true, + 'integer' => true, + 'string' => true, + 'bool' => true, + 'boolean' => true, + 'float' => true, + 'double' => true, + 'null' => true, + 'true' => true, + 'false' => true, + 'scalar' => true, + 'numeric' => true, + 'positive-int' => true, + 'negative-int' => true, + 'non-positive-int' => true, + 'non-negative-int' => true, + 'non-zero-int' => true, + 'unsigned-int' => true, + 'positive-float' => true, + 'negative-float' => true, + 'non-positive-float' => true, + 'non-negative-float' => true, + 'non-zero-float' => true, + 'non-empty-string' => true, + 'numeric-string' => true, + 'lowercase-string' => true, + 'non-empty-lowercase-string' => true, + 'uppercase-string' => true, + 'non-empty-uppercase-string' => true, + 'truthy' => true, + 'falsy' => true, + 'array-key' => true, + ]; + + /** + * Fast lookup set for iterable types. + */ + private const ARRAY_TYPES = [ + 'array' => true, + 'list' => true, + 'iterable' => true, + ]; + /** * Evaluates inline variable validation dynamically based on configuration. */ @@ -49,13 +96,7 @@ public static function checkVariable(mixed $value, string $typeString, string $v /** @var array $config */ $config = \is_array($rawConfig) ? $rawConfig : []; - $checkGenerics = (bool) ($config['generics'] ?? true); - $checkCallables = (bool) ($config['callables'] ?? true); - $checkScalars = (bool) ($config['scalars'] ?? false); - $checkArrays = (bool) ($config['arrays'] ?? false); - $checkObjects = (bool) ($config['objects'] ?? false); - - if (! $checkGenerics && ! $checkCallables && ! $checkScalars && ! $checkArrays && ! $checkObjects) { + if (! self::hasActiveInlineChecks($config)) { return $value; } @@ -67,32 +108,7 @@ public static function checkVariable(mixed $value, string $typeString, string $v $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); } - // Resolve local class-level aliases for the executing context by traversing back the call stack - $className = null; - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - foreach ($trace as $frame) { - $classCandidate = $frame['class'] ?? null; - if ($classCandidate !== null && ! str_starts_with($classCandidate, 'TypePHP\\Internal\\') && ! str_starts_with($classCandidate, 'TypePHP\\Wrapper\\')) { - $className = $classCandidate; - - break; - } - } - - if ($className !== null) { - 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) { - } - } - } + $typeNode = self::resolveCallerContext($typeNode); if (! self::shouldValidateType($typeNode, $config)) { return $value; @@ -106,6 +122,7 @@ public static function checkVariable(mixed $value, string $typeString, string $v return CallableWrapper::wrapTypeNode($typeNode, $value, $cbPrefix, $registry); } + $checkGenerics = (bool) ($config['generics'] ?? true); if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) { $err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context, true); if ($err !== null) { @@ -152,26 +169,8 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } - // Substitute class-level property generics for object instances if (\is_object($objectOrClass)) { - $constructorTarget = $className . '::__construct'; - $contract = ContractParser::parse($constructorTarget); - - $boundTemplates = TemplateManager::getBoundTemplates('none', $objectOrClass, $contract['templates']); - $declaredTemplates = $contract['templates']; - - if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) { - $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates); - - if (class_exists($className) || interface_exists($className) || trait_exists($className)) { - try { - $refClass = new \ReflectionClass($className); - $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); - } catch (\ReflectionException $e) { - // Silently continue if reflection fails - } - } - } + $typeNode = self::substitutePropertyGenerics($typeNode, $objectOrClass, $className); } try { @@ -186,6 +185,83 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $value; } + /** + * Checks if at least one inline variable category is active. + * + * @param array $config + */ + private static function hasActiveInlineChecks(array $config): bool + { + return (bool) ($config['generics'] ?? true) + || (bool) ($config['callables'] ?? true) + || (bool) ($config['scalars'] ?? false) + || (bool) ($config['arrays'] ?? false) + || (bool) ($config['objects'] ?? false); + } + + /** + * Resolves caller class context and applies class-level type aliases to the AST. + */ + private static function resolveCallerContext(TypeNode $typeNode): TypeNode + { + $className = null; + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7); + + foreach ($trace as $frame) { + $classCandidate = $frame['class'] ?? null; + if ($classCandidate !== null && ! str_starts_with($classCandidate, 'TypePHP\\Internal\\') && ! str_starts_with($classCandidate, 'TypePHP\\Wrapper\\')) { + $className = $classCandidate; + + break; + } + } + + if ($className === null || (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className))) { + return $typeNode; + } + + 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) { + // Silently continue if reflection fails + } + + return $typeNode; + } + + /** + * Substitutes generic template types declared on class properties. + */ + private static function substitutePropertyGenerics(TypeNode $typeNode, object $object, string $className): TypeNode + { + $constructorTarget = $className . '::__construct'; + $contract = ContractParser::parse($constructorTarget); + + $boundTemplates = TemplateManager::getBoundTemplates('none', $object, $contract['templates']); + $declaredTemplates = $contract['templates']; + + if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates); + + if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + try { + $refClass = new \ReflectionClass($className); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); + } catch (\ReflectionException $e) { + // Silently continue if reflection fails + } + } + } + + return $typeNode; + } + /** * Parses and caches a type string into a TypeNode AST. */ @@ -247,33 +323,11 @@ private static function shouldValidateType(TypeNode $node, array $config): bool return (bool) ($config['callables'] ?? true); } - if (\in_array($lower, ['array', 'list', 'iterable'], true)) { + if (isset(self::ARRAY_TYPES[$lower])) { 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', - 'uppercase-string', - 'non-empty-uppercase-string', - 'array-key', - ], true)) { + if (isset(self::SCALAR_TYPES[$lower])) { return (bool) ($config['scalars'] ?? false); } @@ -282,7 +336,7 @@ private static function shouldValidateType(TypeNode $node, array $config): bool if ($node instanceof GenericTypeNode) { $lower = strtolower($node->type->name); - if (\in_array($lower, ['array', 'list', 'iterable'], true)) { + if (isset(self::ARRAY_TYPES[$lower])) { return $checkArrays; } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 0f9df53..3a466c2 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -29,153 +29,304 @@ final class ParamChecker /** * @param array $vars */ - public static function checkParams(string $function, array $vars, object|string|null $thisOrClass, TypeValidatorRegistry $registry): ?ErrorMessage - { + public static function checkParams( + string $function, + array $vars, + object|string|null $thisOrClass, + TypeValidatorRegistry $registry + ): ?ErrorMessage { if (! (bool) (Config::get()['params'] ?? true)) { return null; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $effectiveFunction = $function; + $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + $magicError = self::handleMagicCall($effectiveFunction, $vars, $thisObj, $registry); + if ($magicError !== null) { + return $magicError; + } + + $contract = ContractParser::parse($effectiveFunction); + if (\count($contract['types']) === 0) { + return null; + } + + $templates = $contract['templates']; + $aliases = $contract['aliases']; + + self::initializeCallContext($effectiveFunction, $thisObj, $templates); + self::preInferGenericArrayTemplates($contract['types'], $vars, $effectiveFunction, $thisObj, $templates); + + $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $templates); + $declaredTemplates = $templates; + + foreach ($contract['types'] as $paramName => $typeNode) { + if (! \array_key_exists($paramName, $vars)) { + continue; + } - if (str_contains($function, '::')) { - [$classOrTrait, $methodName] = explode('::', $function, 2); - $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null); - if ($actualClassName !== null && $actualClassName !== $classOrTrait) { - $effectiveFunction = $actualClassName . '::' . $methodName; + $err = self::validateSingleParam( + $paramName, + $typeNode, + $vars[$paramName], + $effectiveFunction, + $thisObj, + $templates, + $aliases, + $boundTemplates, + $declaredTemplates, + $registry + ); + + if ($err !== null) { + return $err; } + } - if ($thisObj !== null) { - $targetClass = $actualClassName ?? $classOrTrait; - $traitAliases = HierarchyResolver::getTraitAliases($targetClass); + return null; + } - if (\count($traitAliases) > 0) { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); - foreach ($trace as $frame) { - $frameFunc = $frame['function']; - $frameClass = $frame['class'] ?? ''; - if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { - $effectiveFunction = $targetClass . '::' . $frameFunc; + /** + * Resolves the actual runtime class name vs trait name and matches any active trait aliases. + */ + private static function resolveEffectiveFunction(string $function, object|string|null $thisOrClass, ?object $thisObj): string + { + if (! str_contains($function, '::')) { + return $function; + } - break; - } + [$classOrTrait, $methodName] = explode('::', $function, 2); + $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null); + + $effectiveFunction = ($actualClassName !== null && $actualClassName !== $classOrTrait) + ? $actualClassName . '::' . $methodName + : $function; + + if ($thisObj !== null) { + $targetClass = $actualClassName ?? $classOrTrait; + $traitAliases = HierarchyResolver::getTraitAliases($targetClass); + + if (\count($traitAliases) > 0) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + foreach ($trace as $frame) { + $frameFunc = $frame['function']; + $frameClass = $frame['class'] ?? ''; + if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { + return $targetClass . '::' . $frameFunc; } } } } + return $effectiveFunction; + } + + /** + * Intercepts and delegates dynamic @method calls routed via __call and __callStatic. + * + * @param array $vars + */ + private static function handleMagicCall( + string $effectiveFunction, + array $vars, + ?object $thisObj, + TypeValidatorRegistry $registry + ): ?ErrorMessage { $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); - if ($isMagicCall && (bool) (Config::get()['magic_methods'] ?? true)) { - $magicMethodName = array_values($vars)[0] ?? null; - $magicArgs = array_values($vars)[1] ?? []; + if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) { + return null; + } - if (\is_string($magicMethodName) && \is_array($magicArgs)) { - $className = explode('::', $effectiveFunction, 2)[0]; - $magicContract = ContractParser::parseMagicMethod($className, $magicMethodName); + $magicMethodName = array_values($vars)[0] ?? null; + $magicArgs = array_values($vars)[1] ?? []; - if ($magicContract !== null) { - $magicFunction = $className . '::' . $magicMethodName; - $err = self::validateMagicArguments($magicContract, $magicArgs, $magicFunction, $thisObj, $registry); - if ($err !== null) { - return $err; - } - } - } + if (! \is_string($magicMethodName) || ! \is_array($magicArgs)) { + return null; } - $contract = ContractParser::parse($effectiveFunction); - if (\count($contract['types']) === 0) { + $className = explode('::', $effectiveFunction, 2)[0]; + $magicContract = ContractParser::parseMagicMethod($className, $magicMethodName); + + if ($magicContract === null) { return null; } - $templates = $contract['templates']; - $aliases = $contract['aliases']; + $magicFunction = $className . '::' . $magicMethodName; + + return self::validateMagicArguments($magicContract, $magicArgs, $magicFunction, $thisObj, $registry); + } + /** + * Initializes call stack frames or resolves inherited template bounds. + * + * @param array $templates + */ + private static function initializeCallContext(string $effectiveFunction, ?object $thisObj, array $templates): void + { if ($thisObj === null) { TemplateManager::clearCallBindings($effectiveFunction, $templates); } elseif (str_contains($effectiveFunction, '::')) { $declaringClass = explode('::', $effectiveFunction, 2)[0]; TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); } + } - $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $templates); - $declaredTemplates = $templates; - - foreach ($contract['types'] as $paramName => $typeNode) { - if (! \array_key_exists($paramName, $vars)) { + /** + * Pre-infers generic template parameters from array arguments before callback wrapping. + * + * @param array $types + * @param array $vars + * @param array $templates + */ + private static function preInferGenericArrayTemplates( + array $types, + array $vars, + string $effectiveFunction, + ?object $thisObj, + array $templates + ): void { + foreach ($types as $paramName => $typeNode) { + if (! \array_key_exists($paramName, $vars) || ! \is_array($vars[$paramName]) || \count($vars[$paramName]) === 0) { continue; } - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; - } + $arrVal = $vars[$paramName]; + $sampleKey = array_key_first($arrVal); + $sampleItem = reset($arrVal); - $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); - $val = $vars[$paramName]; + self::inferFromTypeNode($typeNode, $sampleKey, $sampleItem, $effectiveFunction, $thisObj, $templates); + } + } - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; + /** + * Extracts and binds template parameters from GenericTypeNode or ArrayTypeNode. + * + * @param array $templates + */ + private static function inferFromTypeNode( + TypeNode $typeNode, + mixed $sampleKey, + mixed $sampleItem, + string $effectiveFunction, + ?object $thisObj, + array $templates + ): void { + if ($typeNode instanceof GenericTypeNode) { + $baseType = strtolower($typeNode->type->name); + if (! \in_array($baseType, ['array', 'list', 'iterable', 'traversable'], true)) { + return; } - $isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)); + $genericCount = \count($typeNode->genericTypes); + if ($genericCount === 1 && $typeNode->genericTypes[0] instanceof IdentifierTypeNode) { + self::bindTemplateIfUnbound($typeNode->genericTypes[0]->name, $sampleItem, $effectiveFunction, $thisObj, $templates); + } elseif ($genericCount >= 2) { + if ($typeNode->genericTypes[0] instanceof IdentifierTypeNode) { + self::bindTemplateIfUnbound($typeNode->genericTypes[0]->name, $sampleKey, $effectiveFunction, $thisObj, $templates); + } + if ($typeNode->genericTypes[1] instanceof IdentifierTypeNode) { + self::bindTemplateIfUnbound($typeNode->genericTypes[1]->name, $sampleItem, $effectiveFunction, $thisObj, $templates); + } + } + } elseif ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode) { + self::bindTemplateIfUnbound($typeNode->type->name, $sampleItem, $effectiveFunction, $thisObj, $templates); + } + } - $isBareTemplate = ($typeNode instanceof IdentifierTypeNode && isset($templates[$typeNode->name])) - || ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name])); + /** + * Binds a template parameter if it is not already bound in the current scope. + * + * @param array $templates + */ + private static function bindTemplateIfUnbound( + string $templateName, + mixed $sampleValue, + string $effectiveFunction, + ?object $thisObj, + array $templates + ): void { + if (isset($templates[$templateName]) && ! TemplateManager::isBound($effectiveFunction, $thisObj, $templateName)) { + TemplateManager::bindTemplate($effectiveFunction, $thisObj, $templateName, TemplateManager::inferTypeFromValue($sampleValue)); + } + } - $shouldSkipTemplateSub = $isBareTemplate || $isClassStringT; + /** + * Unified single-parameter validation pipeline. + * + * @param array $templates + * @param array $aliases + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function validateSingleParam( + string $paramName, + TypeNode $typeNode, + mixed $val, + string $effectiveFunction, + ?object $thisObj, + array $templates, + array $aliases, + array $boundTemplates, + array $declaredTemplates, + TypeValidatorRegistry $registry + ): ?ErrorMessage { + if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { + $typeNode = $aliases[$typeNode->name]; + } - if (! $shouldSkipTemplateSub && (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0)) { - $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates); - $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); - } + $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); - if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { - $err = self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates); - if ($err !== null) { - return $err; - } + if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { + $typeNode = $aliases[$typeNode->name]; + } - continue; - } + $isClassStringT = ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)); - if (self::getTemplateName($typeNode, $templates) !== null) { - $err = self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry); - if ($err !== null) { - return $err; - } + $isBareTemplate = ($typeNode instanceof IdentifierTypeNode && isset($templates[$typeNode->name])) + || ($typeNode instanceof ArrayTypeNode && $typeNode->type instanceof IdentifierTypeNode && isset($templates[$typeNode->type->name])); - continue; - } + $shouldSkipTemplateSub = $isBareTemplate || $isClassStringT; - $err = $registry->validate($val, $typeNode, $effectiveFunction . '(): Argument $' . $paramName); - if ($err !== null) { - return $err; - } + if (! $shouldSkipTemplateSub && (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0)) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $effectiveFunction, $thisObj); } - return null; + if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { + return self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates); + } + + if (self::getTemplateName($typeNode, $templates) !== null) { + return self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry); + } + + return $registry->validate($val, $typeNode, $effectiveFunction . '(): Argument $' . $paramName); } /** * @param array{return: ?TypeNode, parameters: array, aliases: array, templates: array} $magicContract * @param array $args */ - private static function validateMagicArguments(array $magicContract, array $args, string $function, ?object $thisObj, TypeValidatorRegistry $registry): ?ErrorMessage - { + private static function validateMagicArguments( + array $magicContract, + array $args, + string $function, + ?object $thisObj, + TypeValidatorRegistry $registry + ): ?ErrorMessage { $templates = $magicContract['templates']; $aliases = $magicContract['aliases']; $parameters = $magicContract['parameters']; - if ($thisObj !== null) { - $declaringClass = explode('::', $function, 2)[0]; - TemplateManager::resolveInheritedTemplates($thisObj, $declaringClass); - } else { - TemplateManager::clearCallBindings($function, $templates); - } + self::initializeCallContext($function, $thisObj, $templates); $argValues = array_values($args); $argKeys = array_keys($args); + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); + $declaredTemplates = $templates; + foreach ($parameters as $index => $p) { $paramName = $p['name']; $typeNode = $p['type']; @@ -216,34 +367,19 @@ private static function validateMagicArguments(array $magicContract, array $args continue; } - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; - } - $typeNode = SpecialTypeResolver::resolve($typeNode, $function, $thisObj); - if ($typeNode instanceof IdentifierTypeNode && isset($aliases[$typeNode->name])) { - $typeNode = $aliases[$typeNode->name]; - } + $err = self::validateSingleParam( + $paramName, + $typeNode, + $val, + $function, + $thisObj, + $templates, + $aliases, + $boundTemplates, + $declaredTemplates, + $registry + ); - if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { - $sampleVal = $isVariadic && \is_array($val) ? ($val[0] ?? null) : $val; - $err = self::resolveClassStringTemplate($typeNode, $sampleVal, $paramName, $function, $thisObj, $templates); - if ($err !== null) { - return $err; - } - - continue; - } - - if (self::getTemplateName($typeNode, $templates) !== null) { - $err = self::resolveTemplateParam($typeNode, $val, $paramName, $function, $thisObj, $templates, $registry); - if ($err !== null) { - return $err; - } - - continue; - } - - $err = $registry->validate($val, $typeNode, $function . '(): Argument $' . $paramName); if ($err !== null) { return $err; } @@ -266,8 +402,14 @@ private static function isClassStringTemplate(GenericTypeNode $typeNode, array $ /** * @param array $templates */ - private static function resolveClassStringTemplate(GenericTypeNode $typeNode, mixed $val, string $paramName, string $function, ?object $thisObj, array $templates): ?ErrorMessage - { + private static function resolveClassStringTemplate( + GenericTypeNode $typeNode, + mixed $val, + string $paramName, + string $function, + ?object $thisObj, + array $templates + ): ?ErrorMessage { /** @var IdentifierTypeNode $innerType */ $innerType = $typeNode->genericTypes[0]; $templateName = $innerType->name; @@ -322,8 +464,15 @@ private static function getTemplateName(TypeNode $typeNode, array $templates): ? /** * @param array $templates */ - private static function resolveTemplateParam(TypeNode $typeNode, mixed $val, string $paramName, string $function, ?object $thisObj, array $templates, TypeValidatorRegistry $registry): ?ErrorMessage - { + private static function resolveTemplateParam( + TypeNode $typeNode, + mixed $val, + string $paramName, + string $function, + ?object $thisObj, + array $templates, + TypeValidatorRegistry $registry + ): ?ErrorMessage { $templateName = self::getTemplateName($typeNode, $templates); if ($templateName === null || ! isset($templates[$templateName])) { return null; diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index e48be82..dda9729 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -4,6 +4,7 @@ namespace TypePHP\Internal\Checker; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; @@ -28,139 +29,196 @@ final class ReturnChecker /** * @param array $vars */ - public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass, array $vars, TypeValidatorRegistry $registry, callable $wrapIterableCallback): mixed - { + public static function checkReturn( + string $function, + mixed $value, + object|string|null $thisOrClass, + array $vars, + TypeValidatorRegistry $registry, + callable $wrapIterableCallback + ): mixed { if (! (bool) (Config::get()['returns'] ?? true)) { return $value; } $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $effectiveFunction = $function; + $effectiveFunction = self::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + $magicResult = self::handleMagicReturn( + $effectiveFunction, + $value, + $thisObj, + $vars, + $registry, + $wrapIterableCallback + ); + + if ($magicResult !== null) { + return $magicResult; + } - if (str_contains($function, '::')) { - [$classOrTrait, $methodName] = explode('::', $function, 2); - $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null); - if ($actualClassName !== null && $actualClassName !== $classOrTrait) { - $effectiveFunction = $actualClassName . '::' . $methodName; - } + $contract = ContractParser::parse($effectiveFunction); + $returnTypeNode = $contract['return'] ?? null; - if ($thisObj !== null) { - $targetClass = $actualClassName ?? $classOrTrait; - $traitAliases = HierarchyResolver::getTraitAliases($targetClass); + if ($returnTypeNode === null) { + return $value; + } - if (\count($traitAliases) > 0) { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); - foreach ($trace as $frame) { - $frameFunc = $frame['function']; - $frameClass = $frame['class'] ?? ''; - if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { - $effectiveFunction = $targetClass . '::' . $frameFunc; + return self::evaluateReturn( + $returnTypeNode, + $value, + $effectiveFunction, + $thisObj, + $vars, + $contract['aliases'] ?? [], + $contract['templates'] ?? [], + $registry, + $wrapIterableCallback + ); + } - break; - } - } - } - } + /** + * Resolves the actual runtime class name vs trait name and matches any active trait aliases. + */ + private static function resolveEffectiveFunction(string $function, object|string|null $thisOrClass, ?object $thisObj): string + { + if (! str_contains($function, '::')) { + return $function; } - $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); - if ($isMagicCall && (bool) (Config::get()['magic_methods'] ?? true)) { - $magicMethodName = array_values($vars)[0] ?? null; - $rawMagicArgs = array_values($vars)[1] ?? []; - /** @var array $magicArgs */ - $magicArgs = \is_array($rawMagicArgs) ? $rawMagicArgs : []; - - if (\is_string($magicMethodName)) { - $className = explode('::', $effectiveFunction, 2)[0]; - $magicContract = ContractParser::parseMagicMethod($className, $magicMethodName); - - if ($magicContract !== null && $magicContract['return'] !== null) { - $returnTypeNode = $magicContract['return']; - $magicFunction = $className . '::' . $magicMethodName; - - $err = SpecialTypeResolver::checkThisIdentity($returnTypeNode, $value, $thisObj, $magicFunction); - if ($err !== null) { - return $err; - } + [$classOrTrait, $methodName] = explode('::', $function, 2); + $actualClassName = \is_object($thisOrClass) ? \get_class($thisOrClass) : (\is_string($thisOrClass) ? $thisOrClass : null); - $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $magicFunction, $thisObj); + $effectiveFunction = ($actualClassName !== null && $actualClassName !== $classOrTrait) + ? $actualClassName . '::' . $methodName + : $function; - $aliases = $magicContract['aliases'] ?? []; - if ($returnTypeNode instanceof IdentifierTypeNode && isset($aliases[$returnTypeNode->name])) { - $returnTypeNode = $aliases[$returnTypeNode->name]; - } + if ($thisObj !== null) { + $targetClass = $actualClassName ?? $classOrTrait; + $traitAliases = HierarchyResolver::getTraitAliases($targetClass); - $boundTemplates = TemplateManager::getBoundTemplates($magicFunction, $thisObj, $magicContract['templates']); - $declaredTemplates = $magicContract['templates']; - - if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) { - $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $declaredTemplates); - $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $magicFunction, $thisObj); + if (\count($traitAliases) > 0) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + foreach ($trace as $frame) { + $frameFunc = $frame['function']; + $frameClass = $frame['class'] ?? ''; + if (($frameClass === $actualClassName || $frameClass === $classOrTrait) && isset($traitAliases[$frameFunc])) { + return $targetClass . '::' . $frameFunc; } + } + } + } - $returnTypeNode = self::resolveConditionalReturnType($returnTypeNode, $magicArgs, $boundTemplates, $registry); + return $effectiveFunction; + } - $err = $registry->validate($value, $returnTypeNode, $magicFunction . '(): Return value'); - if ($err !== null) { - return $err; - } + /** + * Intercepts and evaluates return contracts for dynamic @method calls routed via __call and __callStatic. + * + * @param array $vars + */ + private static function handleMagicReturn( + string $effectiveFunction, + mixed $value, + ?object $thisObj, + array $vars, + TypeValidatorRegistry $registry, + callable $wrapIterableCallback + ): mixed { + $isMagicCall = str_ends_with($effectiveFunction, '::__call') || str_ends_with($effectiveFunction, '::__callStatic'); + if (! $isMagicCall || ! (bool) (Config::get()['magic_methods'] ?? true)) { + return null; + } - if (\is_callable($value) && $returnTypeNode instanceof CallableTypeNode) { - return CallableWrapper::wrapTypeNode($returnTypeNode, $value, $magicFunction . '(): Return value', $registry); - } - } - } + $magicMethodName = array_values($vars)[0] ?? null; + $rawMagicArgs = array_values($vars)[1] ?? []; + /** @var array $magicArgs */ + $magicArgs = \is_array($rawMagicArgs) ? $rawMagicArgs : []; + + if (! \is_string($magicMethodName)) { + return null; } - $contract = ContractParser::parse($effectiveFunction); - $returnTypeNode = $contract['return'] ?? null; + $className = explode('::', $effectiveFunction, 2)[0]; + $magicContract = ContractParser::parseMagicMethod($className, $magicMethodName); - if ($returnTypeNode === null) { - return $value; + if ($magicContract === null || $magicContract['return'] === null) { + return null; } - $err = SpecialTypeResolver::checkThisIdentity($returnTypeNode, $value, $thisObj, $effectiveFunction); + $magicFunction = $className . '::' . $magicMethodName; + + return self::evaluateReturn( + $magicContract['return'], + $value, + $magicFunction, + $thisObj, + $magicArgs, + $magicContract['aliases'] ?? [], + $magicContract['templates'] ?? [], + $registry, + $wrapIterableCallback + ); + } + + /** + * Unified return value validation pipeline. + * + * @param array $vars + * @param array $aliases + * @param array $templates + */ + private static function evaluateReturn( + TypeNode $returnTypeNode, + mixed $value, + string $function, + ?object $thisObj, + array $vars, + array $aliases, + array $templates, + TypeValidatorRegistry $registry, + callable $wrapIterableCallback + ): mixed { + $err = SpecialTypeResolver::checkThisIdentity($returnTypeNode, $value, $thisObj, $function); if ($err !== null) { return $err; } - $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $effectiveFunction, $thisObj); + $resolvedType = SpecialTypeResolver::resolve($returnTypeNode, $function, $thisObj); - $aliases = $contract['aliases'] ?? []; - if ($returnTypeNode instanceof IdentifierTypeNode && isset($aliases[$returnTypeNode->name])) { - $returnTypeNode = $aliases[$returnTypeNode->name]; + if ($resolvedType instanceof IdentifierTypeNode && isset($aliases[$resolvedType->name])) { + $resolvedType = $aliases[$resolvedType->name]; } - $boundTemplates = TemplateManager::getBoundTemplates($effectiveFunction, $thisObj, $contract['templates']); - $declaredTemplates = $contract['templates']; + $boundTemplates = TemplateManager::getBoundTemplates($function, $thisObj, $templates); - if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) { - $returnTypeNode = TemplateSubstitutor::substitute($returnTypeNode, $boundTemplates, $declaredTemplates); - $returnTypeNode = SpecialTypeResolver::resolve($returnTypeNode, $effectiveFunction, $thisObj); + if (\count($boundTemplates) > 0 || \count($templates) > 0) { + $resolvedType = TemplateSubstitutor::substitute($resolvedType, $boundTemplates, $templates); + $resolvedType = SpecialTypeResolver::resolve($resolvedType, $function, $thisObj); } - $returnTypeNode = self::resolveConditionalReturnType($returnTypeNode, $vars, $boundTemplates, $registry); + $resolvedType = self::resolveConditionalReturnType($resolvedType, $vars, $boundTemplates, $registry); - $err = $registry->validate($value, $returnTypeNode, $effectiveFunction . '(): Return value'); + $err = $registry->validate($value, $resolvedType, $function . '(): Return value'); if ($err !== null) { return $err; } - if (\is_callable($value) && $returnTypeNode instanceof CallableTypeNode) { - return CallableWrapper::wrapTypeNode($returnTypeNode, $value, $effectiveFunction . '(): Return value', $registry); + if (\is_callable($value) && $resolvedType instanceof CallableTypeNode) { + return CallableWrapper::wrapTypeNode($resolvedType, $value, $function . '(): Return value', $registry); } if ($value instanceof \Traversable) { $baseName = ''; - if ($returnTypeNode instanceof IdentifierTypeNode) { - $baseName = strtolower(ltrim($returnTypeNode->name, '\\')); - } elseif ($returnTypeNode instanceof GenericTypeNode) { - $baseName = strtolower(ltrim($returnTypeNode->type->name, '\\')); + if ($resolvedType instanceof IdentifierTypeNode) { + $baseName = strtolower(ltrim($resolvedType->name, '\\')); + } elseif ($resolvedType instanceof GenericTypeNode) { + $baseName = strtolower(ltrim($resolvedType->type->name, '\\')); } - $standardIterables = ['iterable', 'traversable', 'iterator', 'generator', 'iteratoraggregate', 'array']; - if ($baseName === '' || \in_array($baseName, $standardIterables, true)) { - return $wrapIterableCallback($effectiveFunction, 'return', $value); + $genericIterables = ['iterable', 'traversable', 'iterator', 'generator']; + if (\in_array($baseName, $genericIterables, true)) { + return $wrapIterableCallback($function, 'return', $value); } } @@ -180,52 +238,78 @@ private static function resolveConditionalReturnType( TypeValidatorRegistry $registry ): TypeNode { if ($returnTypeNode instanceof ConditionalTypeForParameterNode) { - $paramName = ltrim($returnTypeNode->parameterName, '$'); - $paramValue = $vars[$paramName] ?? null; + return self::resolveParameterConditional($returnTypeNode, $vars, $boundTemplates, $registry); + } - $targetErr = $registry->validate($paramValue, $returnTypeNode->targetType, 'condition'); - $isTargetMatch = ($targetErr === null); + if ($returnTypeNode instanceof ConditionalTypeNode) { + return self::resolveTemplateConditional($returnTypeNode, $vars, $boundTemplates, $registry); + } - if ($returnTypeNode->negated) { - $isTargetMatch = ! $isTargetMatch; - } + return $returnTypeNode; + } - $selectedBranch = $isTargetMatch ? $returnTypeNode->if : $returnTypeNode->else; + /** + * Resolves parameter-based conditional return types ($param is Target ? If : Else). + * + * @param array $vars + * @param array $boundTemplates + */ + private static function resolveParameterConditional( + ConditionalTypeForParameterNode $node, + array $vars, + array $boundTemplates, + TypeValidatorRegistry $registry + ): TypeNode { + $paramName = ltrim($node->parameterName, '$'); + $paramValue = $vars[$paramName] ?? null; - return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); + $targetErr = $registry->validate($paramValue, $node->targetType, 'condition'); + $isTargetMatch = ($targetErr === null); + + if ($node->negated) { + $isTargetMatch = ! $isTargetMatch; } - if ($returnTypeNode instanceof ConditionalTypeNode) { - /** @var TypeNode|IdentifierTypeNode $subjectTypeNode */ - $subjectTypeNode = $returnTypeNode->subjectType; + $selectedBranch = $isTargetMatch ? $node->if : $node->else; - if ($subjectTypeNode instanceof IdentifierTypeNode && isset($boundTemplates[$subjectTypeNode->name])) { - /** @var TypeNode $subjectTypeNode */ - $subjectTypeNode = $boundTemplates[$subjectTypeNode->name]; - } + return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); + } - $subStr = (string) $subjectTypeNode; - /** @var TypeNode $targetTypeNode */ - $targetTypeNode = $returnTypeNode->targetType; - $targetStr = (string) $targetTypeNode; - - $isTargetMatch = ($subStr === $targetStr); - if (! $isTargetMatch) { - $isTargetMatch = ClassNameValidator::isValid($subStr) && ClassNameValidator::isValid($targetStr) && - (class_exists($subStr) || interface_exists($subStr)) && - (class_exists($targetStr) || interface_exists($targetStr)) && - is_a($subStr, $targetStr, true); - } + /** + * Resolves template-based conditional return types (T is Target ? If : Else). + * + * @param array $vars + * @param array $boundTemplates + */ + private static function resolveTemplateConditional( + ConditionalTypeNode $node, + array $vars, + array $boundTemplates, + TypeValidatorRegistry $registry + ): TypeNode { + $subjectTypeNode = $node->subjectType; - if ($returnTypeNode->negated) { - $isTargetMatch = ! $isTargetMatch; - } + if ($subjectTypeNode instanceof IdentifierTypeNode && isset($boundTemplates[$subjectTypeNode->name])) { + $subjectTypeNode = $boundTemplates[$subjectTypeNode->name]; + } - $selectedBranch = $isTargetMatch ? $returnTypeNode->if : $returnTypeNode->else; + $subStr = (string) $subjectTypeNode; + $targetStr = (string) $node->targetType; - return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); + $isTargetMatch = ($subStr === $targetStr); + if (! $isTargetMatch) { + $isTargetMatch = ClassNameValidator::isValid($subStr) && ClassNameValidator::isValid($targetStr) && + (class_exists($subStr) || interface_exists($subStr)) && + (class_exists($targetStr) || interface_exists($targetStr)) && + is_a($subStr, $targetStr, true); } - return $returnTypeNode; + if ($node->negated) { + $isTargetMatch = ! $isTargetMatch; + } + + $selectedBranch = $isTargetMatch ? $node->if : $node->else; + + return self::resolveConditionalReturnType($selectedBranch, $vars, $boundTemplates, $registry); } } diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 90f6e55..6513285 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -5,8 +5,10 @@ namespace TypePHP\Internal; use TypePHP\Contract\ContractParser; +use TypePHP\Contract\HierarchyResolver; use TypePHP\Extension\ExtensionInterface; use TypePHP\Extension\ExtensionManager; +use TypePHP\Resolver\TemplateManager; /** * Global configuration manager for loading and dynamically overriding settings. @@ -149,5 +151,7 @@ public static function reset(): void self::$projectRoot = null; ContractParser::reset(); + TemplateManager::reset(); + HierarchyResolver::reset(); } } diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 6ba4fa6..1838042 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -29,34 +29,26 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $docText = $doc !== null ? $doc->getText() : ''; - // Per-Function/Method Suppression Tag - if ((bool) (Config::get()['respect_ignore_tags'] ?? true) && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable'))) { - return; // Skip injecting contract checks for this specific function/method! + if (self::shouldSkipInjection($docText)) { + return; } $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); - $hasParam = $isClassMethod || str_contains($docText, '@param'); - - // Never inject return checks into constructors, destructors, or clone methods + $hasParam = $isClassMethod || str_contains($docText, '@param') || str_contains($docText, '@phpstan-param') || str_contains($docText, '@psalm-param'); $hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')); if (! $hasParam && ! $hasReturn) { return; } + $thisArg = self::resolveThisArg($isClassMethod, $node); $isNativeVoid = $node->returnType instanceof Node\Identifier && strtolower($node->returnType->name) === 'void'; - $hasThis = $isClassMethod && ! $node->isStatic(); - - $thisArg = $hasThis - ? new Node\Expr\Variable('this') - : ($isClassMethod ? new Node\Expr\ClassConstFetch(new Node\Name('static'), 'class') : new Node\Expr\ConstFetch(new Node\Name('null'))); $injectedStmts = []; - if ($hasParam) { - $injectedStmts = self::buildParamInjections($node, $docText, $thisArg, $isClassMethod); + $injectedStmts = self::buildParamInjections($node->params, $docText, $thisArg, $isClassMethod); } if ($hasReturn) { @@ -68,13 +60,32 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $node->stmts = array_merge($injectedStmts, $node->stmts); } + private static function shouldSkipInjection(string $docText): bool + { + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); + + return $shouldRespectIgnore && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable')); + } + + private static function resolveThisArg(bool $isClassMethod, Node\Stmt\Function_|Node\Stmt\ClassMethod $node): Node\Expr + { + if (! $isClassMethod) { + return new Node\Expr\ConstFetch(new Node\Name('null')); + } + + /** @var Node\Stmt\ClassMethod $node */ + return $node->isStatic() + ? new Node\Expr\ClassConstFetch(new Node\Name('static'), 'class') + : new Node\Expr\Variable('this'); + } + private static function isGenerator(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): bool { if ($node->stmts === null) { return false; } - $visitor = new class() extends NodeVisitorAbstract { + $visitor = new class () extends NodeVisitorAbstract { public bool $isGen = false; public function enterNode(Node $n): ?int @@ -101,112 +112,257 @@ public function enterNode(Node $n): ?int } /** + * @param array $params + * * @return array */ private static function buildParamInjections( - Node\Stmt\Function_|Node\Stmt\ClassMethod $node, + array $params, string $docText, Node\Expr $thisArg, bool $isClassMethod ): array { - $injectedStmts = []; + $injectedStmts = [self::buildSetupScopeStmt($thisArg)]; + + $callableWrappers = self::buildParamWrappers( + $params, + '\TypePHP\Internal\RuntimeTypeChecker::wrapCallable', + $thisArg, + $isClassMethod || str_contains($docText, 'callable') || str_contains($docText, 'Closure') + ); + + $iterableWrappers = self::buildParamWrappers( + $params, + '\TypePHP\Internal\RuntimeTypeChecker::wrapIterable', + $thisArg, + str_contains($docText, 'iterable') || str_contains($docText, 'Traversable') || str_contains($docText, 'Generator') || str_contains($docText, 'Iterator') + ); + + return array_merge($injectedStmts, $callableWrappers, $iterableWrappers); + } + + private static function buildSetupScopeStmt(Node\Expr $thisArg): Node\Stmt\If_ + { + $checkCall = new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::setupScope'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg(new Node\Expr\FuncCall(new Node\Name('get_defined_vars'))), + new Node\Arg($thisArg), + ] + ); + + $throwStmt = self::buildTypeErrorThrowStmt(new Node\Expr\Variable('__typephpErr')); $ifStmt = new Node\Stmt\If_( new Node\Expr\Instanceof_( - new Node\Expr\Assign( - new Node\Expr\Variable('__typephpErr'), - new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::setupScope'), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Expr\FuncCall(new Node\Name('get_defined_vars'))), - new Node\Arg($thisArg), - ] - ) - ), + new Node\Expr\Assign(new Node\Expr\Variable('__typephpErr'), $checkCall), new Node\Name('\TypePHP\Internal\ErrorMessage') ), - [ - 'stmts' => [ - new Node\Stmt\Expression( - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', + ['stmts' => [$throwStmt]] + ); + + $ifStmt->setAttribute('typephp_injected', true); + + return $ifStmt; + } + + /** + * @param array $params + * + * @return array + */ + private static function buildParamWrappers(array $params, string $wrapperFunc, Node\Expr $thisArg, bool $shouldWrap): array + { + if (! $shouldWrap) { + return []; + } + + $wrappers = []; + foreach ($params as $param) { + if ($param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { + $paramName = $param->var->name; + $expr = new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\Variable($paramName), + new Node\Expr\FuncCall( + new Node\Name($wrapperFunc), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg(new Node\Scalar\String_($paramName)), + new Node\Arg(new Node\Expr\Variable($paramName)), + new Node\Arg($thisArg), + ] + ) + ) + ); + $expr->setAttribute('typephp_injected', true); + $wrappers[] = $expr; + } + } + + return $wrappers; + } + + public static function buildTypeErrorThrowStmt(Node\Expr $errorVar): Node\Stmt\Expression + { + return new Node\Stmt\Expression( + new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name('\TypePHP\Internal\ErrorFactory'), + 'prepareException', + [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name('\TypePHP\Exception\TypeError'), [ new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall( - new Node\Expr\Variable('__typephpErr'), - 'getMessage' - ) - ), - ] - ) + new Node\Expr\MethodCall($errorVar, 'getMessage') ), ] ) - ) - ), - ], + ), + ] + ) + ) + ); + } + + public static function buildReturnCheckCall(Node\Expr $exprToWrap, Node\Expr $thisArg): Node\Expr\FuncCall + { + return new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkReturn'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg($exprToWrap), + new Node\Arg($thisArg), + new Node\Arg(new Node\Expr\FuncCall(new Node\Name('get_defined_vars'))), ] ); + } + /** + * @return array + */ + public static function buildVoidReturnGuard(Node\Expr\FuncCall $checkCall): array + { + $ifStmt = new Node\Stmt\If_( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpRet'), $checkCall), + new Node\Name('\TypePHP\Internal\ErrorMessage') + ), + ['stmts' => [self::buildTypeErrorThrowStmt(new Node\Expr\Variable('__typephpRet'))]] + ); $ifStmt->setAttribute('typephp_injected', true); - $injectedStmts[] = $ifStmt; - - if ($isClassMethod || str_contains($docText, 'callable') || str_contains($docText, 'Closure')) { - foreach ($node->params as $param) { - if ($param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { - $paramName = $param->var->name; - $expr = new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\Variable($paramName), - new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::wrapCallable'), + + $retStmt = new Node\Stmt\Return_(null); + $retStmt->setAttribute('typephp_injected', true); + + return [$ifStmt, $retStmt]; + } + + public static function buildTernaryReturnExpr(Node\Expr\FuncCall $checkCall): Node\Expr\Ternary + { + return new Node\Expr\Ternary( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpRet'), $checkCall), + new Node\Name('\TypePHP\Internal\ErrorMessage') + ), + new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name('\TypePHP\Internal\ErrorFactory'), + 'prepareException', + [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name('\TypePHP\Exception\TypeError'), [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Scalar\String_($paramName)), - new Node\Arg(new Node\Expr\Variable($paramName)), - new Node\Arg($thisArg), + new Node\Arg( + new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpRet'), 'getMessage') + ), ] ) - ) - ); - $expr->setAttribute('typephp_injected', true); - $injectedStmts[] = $expr; - } - } - } + ), + ] + ) + ), + new Node\Expr\Variable('__typephpRet') + ); + } + + public static function buildWrappedYieldNode(Node\Expr\Yield_ $n, Node\Expr $thisArg): Node\Expr\Ternary + { + $checkYieldCall = new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkYield'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg($n->key ?? new Node\Expr\ConstFetch(new Node\Name('null'))), + new Node\Arg($n->value ?? new Node\Expr\ConstFetch(new Node\Name('null'))), + new Node\Arg($thisArg), + ] + ); - if (str_contains($docText, 'iterable') || str_contains($docText, 'Traversable') || str_contains($docText, 'Generator') || str_contains($docText, 'Iterator')) { - foreach ($node->params as $param) { - if ($param->var instanceof Node\Expr\Variable && \is_string($param->var->name)) { - $paramName = $param->var->name; - $expr = new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\Variable($paramName), - new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::wrapIterable'), + $n->value = new Node\Expr\Ternary( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpYld'), $checkYieldCall), + new Node\Name('\TypePHP\Internal\ErrorMessage') + ), + new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name('\TypePHP\Internal\ErrorFactory'), + 'prepareException', + [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name('\TypePHP\Exception\TypeError'), [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Scalar\String_($paramName)), - new Node\Arg(new Node\Expr\Variable($paramName)), - new Node\Arg($thisArg), + new Node\Arg( + new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpYld'), 'getMessage') + ), ] ) - ) - ); - $expr->setAttribute('typephp_injected', true); - $injectedStmts[] = $expr; - } - } - } + ), + new Node\Arg(new Node\Scalar\LNumber($n->getStartLine())), + ] + ) + ), + new Node\Expr\Variable('__typephpYld') + ); + + $checkSendCall = new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkSend'), + [ + new Node\Arg(new Node\Scalar\MagicConst\Method()), + new Node\Arg($n), + new Node\Arg($thisArg), + ] + ); - return $injectedStmts; + return new Node\Expr\Ternary( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpSnd'), $checkSendCall), + new Node\Name('\TypePHP\Internal\ErrorMessage') + ), + new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name('\TypePHP\Internal\ErrorFactory'), + 'prepareException', + [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name('\TypePHP\Exception\TypeError'), + [ + new Node\Arg( + new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpSnd'), 'getMessage') + ), + ] + ) + ), + ] + ) + ), + new Node\Expr\Variable('__typephpSnd') + ); } /** @@ -217,8 +373,10 @@ private static function buildParamInjections( private static function wrapGeneratorReturns(array $stmts, Node\Expr $thisArg): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg) extends NodeVisitorAbstract { - public function __construct(private Node\Expr $thisArg) {} + $traverser->addVisitor(new class ($thisArg) extends NodeVisitorAbstract { + public function __construct(private Node\Expr $thisArg) + { + } public function enterNode(Node $n): int|Node|null { @@ -233,77 +391,7 @@ public function enterNode(Node $n): int|Node|null $n->setAttribute('typephp_wrapped', true); - $checkYieldCall = new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkYield'), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg($n->key ?? new Node\Expr\ConstFetch(new Node\Name('null'))), - new Node\Arg($n->value ?? new Node\Expr\ConstFetch(new Node\Name('null'))), - new Node\Arg($this->thisArg), - ] - ); - - $n->value = new Node\Expr\Ternary( - new Node\Expr\Instanceof_( - new Node\Expr\Assign(new Node\Expr\Variable('__typephpYld'), $checkYieldCall), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpYld'), 'getMessage') - ), - ] - ) - ), - new Node\Arg(new Node\Scalar\LNumber($n->getStartLine())), - ] - ) - ), - new Node\Expr\Variable('__typephpYld') - ); - - $checkSendCall = new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkSend'), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg($n), - new Node\Arg($this->thisArg), - ] - ); - - return new Node\Expr\Ternary( - new Node\Expr\Instanceof_( - new Node\Expr\Assign(new Node\Expr\Variable('__typephpSnd'), $checkSendCall), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpSnd'), 'getMessage') - ), - ] - ) - ), - ] - ) - ), - new Node\Expr\Variable('__typephpSnd') - ); + return FunctionContractInjector::buildWrappedYieldNode($n, $this->thisArg); } if ($n instanceof Node\Expr\YieldFrom) { @@ -342,11 +430,12 @@ public function enterNode(Node $n): int|Node|null private static function wrapNonGeneratorReturns(array $stmts, Node\Expr $thisArg, bool $isNativeVoid): array { $traverser = new NodeTraverser(); - $traverser->addVisitor(new class($thisArg, $isNativeVoid) extends NodeVisitorAbstract { + $traverser->addVisitor(new class ($thisArg, $isNativeVoid) extends NodeVisitorAbstract { public function __construct( private Node\Expr $thisArg, private bool $isNativeVoid - ) {} + ) { + } public function enterNode(Node $n): int|array|null { @@ -356,96 +445,13 @@ public function enterNode(Node $n): int|array|null if ($n instanceof Node\Stmt\Return_) { $exprToWrap = $n->expr ?? new Node\Expr\ConstFetch(new Node\Name('null')); - - $checkReturnCall = new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkReturn'), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg($exprToWrap), - new Node\Arg($this->thisArg), - new Node\Arg(new Node\Expr\FuncCall(new Node\Name('get_defined_vars'))), - ] - ); + $checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg); if ($this->isNativeVoid) { - $ifStmt = new Node\Stmt\If_( - new Node\Expr\Instanceof_( - new Node\Expr\Assign( - new Node\Expr\Variable('__typephpRet'), - $checkReturnCall - ), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - [ - 'stmts' => [ - new Node\Stmt\Expression( - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall( - new Node\Expr\Variable('__typephpRet'), - 'getMessage' - ) - ), - ] - ) - ), - ] - ) - ) - ), - ], - ] - ); - $ifStmt->setAttribute('typephp_injected', true); - - $retStmt = new Node\Stmt\Return_(null); - $retStmt->setAttribute('typephp_injected', true); - - return [ - $ifStmt, - $retStmt, - ]; + return FunctionContractInjector::buildVoidReturnGuard($checkCall); } - $n->expr = new Node\Expr\Ternary( - new Node\Expr\Instanceof_( - new Node\Expr\Assign( - new Node\Expr\Variable('__typephpRet'), - $checkReturnCall - ), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall( - new Node\Expr\Variable('__typephpRet'), - 'getMessage' - ) - ), - ] - ) - ), - ] - ) - ), - new Node\Expr\Variable('__typephpRet') - ); + $n->expr = FunctionContractInjector::buildTernaryReturnExpr($checkCall); } return null; @@ -457,93 +463,12 @@ public function enterNode(Node $n): int|array|null $lastStmt = end($newStmts); if (! $lastStmt instanceof Node\Stmt\Return_ && ! ($lastStmt instanceof Node\Stmt\Expression && $lastStmt->expr instanceof Node\Expr\Throw_)) { - $checkReturnCall = new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::checkReturn'), - [ - new Node\Arg(new Node\Scalar\MagicConst\Method()), - new Node\Arg(new Node\Expr\ConstFetch(new Node\Name('null'))), - new Node\Arg($thisArg), - new Node\Arg(new Node\Expr\FuncCall(new Node\Name('get_defined_vars'))), - ] - ); + $checkCall = self::buildReturnCheckCall(new Node\Expr\ConstFetch(new Node\Name('null')), $thisArg); if ($isNativeVoid) { - $ifStmt = new Node\Stmt\If_( - new Node\Expr\Instanceof_( - new Node\Expr\Assign( - new Node\Expr\Variable('__typephpRet'), - $checkReturnCall - ), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - [ - 'stmts' => [ - new Node\Stmt\Expression( - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall( - new Node\Expr\Variable('__typephpRet'), - 'getMessage' - ) - ), - ] - ) - ), - ] - ) - ) - ), - ], - ] - ); - $ifStmt->setAttribute('typephp_injected', true); - $newStmts[] = $ifStmt; - - $retStmt = new Node\Stmt\Return_(null); - $retStmt->setAttribute('typephp_injected', true); - $newStmts[] = $retStmt; + $newStmts = array_merge($newStmts, self::buildVoidReturnGuard($checkCall)); } else { - $retStmt = new Node\Stmt\Return_( - new Node\Expr\Ternary( - new Node\Expr\Instanceof_( - new Node\Expr\Assign( - new Node\Expr\Variable('__typephpRet'), - $checkReturnCall - ), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', - [ - new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall( - new Node\Expr\Variable('__typephpRet'), - 'getMessage' - ) - ), - ] - ) - ), - ] - ) - ), - new Node\Expr\Variable('__typephpRet') - ) - ); + $retStmt = new Node\Stmt\Return_(self::buildTernaryReturnExpr($checkCall)); $retStmt->setAttribute('typephp_injected', true); $newStmts[] = $retStmt; } diff --git a/src/Internal/Visitor/PropertyHookInjector.php b/src/Internal/Visitor/PropertyHookInjector.php index 6919fd3..925ed82 100644 --- a/src/Internal/Visitor/PropertyHookInjector.php +++ b/src/Internal/Visitor/PropertyHookInjector.php @@ -16,12 +16,7 @@ final class PropertyHookInjector { public static function process(Node\Stmt\Property $node): void { - if (! isset($node->hooks) || ! \is_array($node->hooks) || $node->hooks === []) { - return; - } - - $doc = $node->getDocComment(); - if ((bool) (Config::get()['respect_ignore_tags'] ?? true) && $doc !== null && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable'))) { + if (self::shouldSkipInjection($node)) { return; } @@ -31,64 +26,92 @@ public static function process(Node\Stmt\Property $node): void $hookName = strtolower($hook->name->toString()); if ($hookName === 'get') { - if ($hook->body instanceof Node\Expr) { - $checkCall = NodeBuilder::createPropertyCheckCall($hook->body, new Node\Expr\Variable('this'), $propertyName); - $hook->body = NodeBuilder::createTernaryThrowExpr($checkCall); - } elseif (\is_array($hook->body)) { - $hook->body = self::wrapHookReturnStatements($hook->body, $propertyName); - } + self::processGetHook($hook, $propertyName); } elseif ($hookName === 'set') { - $paramName = $hook->params !== [] && $hook->params[0]->var instanceof Node\Expr\Variable && \is_string($hook->params[0]->var->name) - ? $hook->params[0]->var->name - : 'value'; - - $checkCall = NodeBuilder::createPropertyCheckCall(new Node\Expr\Variable($paramName), new Node\Expr\Variable('this'), $propertyName); - - if (\is_array($hook->body)) { - $paramCheckStmt = new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\Variable($paramName), - NodeBuilder::createTernaryThrowExpr($checkCall) - ) - ); - $paramCheckStmt->setAttribute('typephp_injected', true); - array_unshift($hook->body, $paramCheckStmt); - } elseif ($hook->body instanceof Node\Expr) { - // Bypass php-parser formatting bugs by keeping short hooks as Expressions - $hook->body = new Node\Expr\Ternary( - new Node\Expr\Instanceof_( - new Node\Expr\Assign( - new Node\Expr\Variable('__typephpVal'), - $checkCall - ), - new Node\Name('\TypePHP\Internal\ErrorMessage') - ), - new Node\Expr\Throw_( - new Node\Expr\StaticCall( - new Node\Name('\TypePHP\Internal\ErrorFactory'), - 'prepareException', + self::processSetHook($hook, $propertyName); + } + } + } + + private static function shouldSkipInjection(Node\Stmt\Property $node): bool + { + if (! isset($node->hooks) || ! \is_array($node->hooks) || $node->hooks === []) { + return true; + } + + $doc = $node->getDocComment(); + if ($doc === null) { + return false; + } + + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); + + return $shouldRespectIgnore && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable')); + } + + private static function processGetHook(Node\PropertyHook $hook, string $propertyName): void + { + if ($hook->body instanceof Node\Expr) { + $checkCall = NodeBuilder::createPropertyCheckCall($hook->body, new Node\Expr\Variable('this'), $propertyName); + $hook->body = NodeBuilder::createTernaryThrowExpr($checkCall); + } elseif (\is_array($hook->body)) { + $hook->body = self::wrapHookReturnStatements($hook->body, $propertyName); + } + } + + private static function processSetHook(Node\PropertyHook $hook, string $propertyName): void + { + $paramName = self::extractSetParamName($hook); + $checkCall = NodeBuilder::createPropertyCheckCall(new Node\Expr\Variable($paramName), new Node\Expr\Variable('this'), $propertyName); + + if (\is_array($hook->body)) { + $paramCheckStmt = new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\Variable($paramName), + NodeBuilder::createTernaryThrowExpr($checkCall) + ) + ); + $paramCheckStmt->setAttribute('typephp_injected', true); + array_unshift($hook->body, $paramCheckStmt); + } elseif ($hook->body instanceof Node\Expr) { + $hook->body = self::buildExpressionSetHookTernary($checkCall, $hook->body); + } + } + + private static function extractSetParamName(Node\PropertyHook $hook): string + { + return ($hook->params !== [] && $hook->params[0]->var instanceof Node\Expr\Variable && \is_string($hook->params[0]->var->name)) + ? $hook->params[0]->var->name + : 'value'; + } + + private static function buildExpressionSetHookTernary(Node\Expr\FuncCall $checkCall, Node\Expr $assignmentExpr): Node\Expr\Ternary + { + return new Node\Expr\Ternary( + new Node\Expr\Instanceof_( + new Node\Expr\Assign(new Node\Expr\Variable('__typephpVal'), $checkCall), + new Node\Name('\TypePHP\Internal\ErrorMessage') + ), + new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name('\TypePHP\Internal\ErrorFactory'), + 'prepareException', + [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name('\TypePHP\Exception\TypeError'), [ new Node\Arg( - new Node\Expr\New_( - new Node\Name('\TypePHP\Exception\TypeError'), - [ - new Node\Arg( - new Node\Expr\MethodCall( - new Node\Expr\Variable('__typephpVal'), - 'getMessage' - ) - ), - ] - ) + new Node\Expr\MethodCall(new Node\Expr\Variable('__typephpVal'), 'getMessage') ), ] ) ), - $hook->body // False branch evaluates the original assignment - ); - } - } - } + ] + ) + ), + $assignmentExpr + ); } /** diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index f3fb4a9..f33754f 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -50,6 +50,16 @@ final class TemplateManager */ public static ?object $pendingCloneSource = null; + /** + * Resets all static generic template bindings and call stack frames. Useful for test isolation. + */ + public static function reset(): void + { + self::$instanceTemplateBindings = null; + self::$callStackBindings = []; + self::$pendingCloneSource = null; + } + /** * Copies bound generic template types from a source object to a cloned target object. */ @@ -229,9 +239,7 @@ public static function getBoundType(string $function, ?object $thisObj, string $ public static function bindTemplate(string $function, ?object $thisObj, string $templateName, TypeNode $inferredType): void { if ($thisObj !== null) { - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); - } + self::$instanceTemplateBindings ??= new WeakMap(); $bindings = self::$instanceTemplateBindings[$thisObj] ?? []; $bindings[$templateName] = $inferredType; self::$instanceTemplateBindings[$thisObj] = $bindings; @@ -254,11 +262,7 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t $className = \get_class($instance); } - if (! is_a($instance, $className)) { - return null; - } - - if (! ClassNameValidator::isValid($className) || (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className))) { + if (! is_a($instance, $className) || ! ClassNameValidator::isValid($className) || (! class_exists($className) && ! interface_exists($className) && ! trait_exists($className))) { return null; } @@ -266,77 +270,111 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t try { $ref = new \ReflectionClass($className); - $classHierarchy = HierarchyResolver::getClassHierarchy($ref); - - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); - - $templates = []; - $classVariances = []; + [$templates, $classVariances] = self::collectHierarchyTemplatesAndVariances($ref); - // Collect template parameters across the entire class/interface hierarchy with priority! - foreach ($classHierarchy as $hierClass) { - $classDoc = $hierClass->getDocComment(); - if ($classDoc !== false) { - $classTokens = new TokenIterator($lexer->tokenize($classDoc)); - $classPhpDocNode = $phpDocParser->parse($classTokens); + self::$instanceTemplateBindings ??= new WeakMap(); + $templateList = array_values($templates); - $hierTemplates = DocblockExtractor::extractTemplates($classPhpDocNode); - $hierVariances = DocblockExtractor::extractTemplateVariances($classPhpDocNode); - - foreach ($hierTemplates as $tName => $tagNode) { - if (! isset($templates[$tName])) { - $templates[$tName] = $tagNode; - $classVariances[$tName] = match ($hierVariances[$tName] ?? 'invariant') { - 'covariant' => GenericTypeNode::VARIANCE_COVARIANT, - 'contravariant' => GenericTypeNode::VARIANCE_CONTRAVARIANT, - default => GenericTypeNode::VARIANCE_INVARIANT, - }; - } - } + foreach ($templateList as $index => $templateTag) { + $err = self::bindSingleTemplateArgument($instance, $className, $typeNode, $index, $templateTag, $classVariances, $context, $forceBind); + if ($err !== null) { + return $err; } } + } catch (\Throwable $e) { + // Silently ignore reflection or parsing errors + } - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); + return null; + } + + /** + * @param \ReflectionClass $ref + * + * @return array{0: array, 1: array} + */ + private static function collectHierarchyTemplatesAndVariances(\ReflectionClass $ref): array + { + $classHierarchy = HierarchyResolver::getClassHierarchy($ref); + [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); + + $templates = []; + $classVariances = []; + + foreach ($classHierarchy as $hierClass) { + $classDoc = $hierClass->getDocComment(); + if ($classDoc === false) { + continue; } - $templateList = array_values($templates); + $classTokens = new TokenIterator($lexer->tokenize($classDoc)); + $classPhpDocNode = $phpDocParser->parse($classTokens); - foreach ($templateList as $index => $templateTag) { - if (isset($typeNode->genericTypes[$index])) { - $expectedTypeNode = $typeNode->genericTypes[$index]; + $hierTemplates = DocblockExtractor::extractTemplates($classPhpDocNode); + $hierVariances = DocblockExtractor::extractTemplateVariances($classPhpDocNode); - $usageVariance = $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT; - $declaredVariance = $classVariances[$templateTag->name] ?? GenericTypeNode::VARIANCE_INVARIANT; + foreach ($hierTemplates as $tName => $tagNode) { + if (! isset($templates[$tName])) { + $templates[$tName] = $tagNode; + $classVariances[$tName] = match ($hierVariances[$tName] ?? 'invariant') { + 'covariant' => GenericTypeNode::VARIANCE_COVARIANT, + 'contravariant' => GenericTypeNode::VARIANCE_CONTRAVARIANT, + default => GenericTypeNode::VARIANCE_INVARIANT, + }; + } + } + } - $variance = ($usageVariance !== GenericTypeNode::VARIANCE_INVARIANT) - ? $usageVariance - : $declaredVariance; + return [$templates, $classVariances]; + } - $templateName = $templateTag->name; - $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; + /** + * @param array $classVariances + */ + private static function bindSingleTemplateArgument( + object $instance, + string $className, + GenericTypeNode $typeNode, + int $index, + TemplateTagValueNode $templateTag, + array $classVariances, + string $context, + bool $forceBind + ): ?ErrorMessage { + if (! isset($typeNode->genericTypes[$index])) { + return null; + } - if (isset($existingBindings[$templateName])) { - $existingTypeNode = $existingBindings[$templateName]; + if (self::$instanceTemplateBindings === null) { + self::$instanceTemplateBindings = new WeakMap(); + } - $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); + $expectedTypeNode = $typeNode->genericTypes[$index]; + $usageVariance = $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT; + $declaredVariance = $classVariances[$templateTag->name] ?? GenericTypeNode::VARIANCE_INVARIANT; - if (! $valid) { - return ErrorFactory::createError( - $context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" - ); - } - } + $variance = ($usageVariance !== GenericTypeNode::VARIANCE_INVARIANT) + ? $usageVariance + : $declaredVariance; - if ($forceBind || ! isset($existingBindings[$templateName])) { - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - $bindings[$templateName] = $expectedTypeNode; - self::$instanceTemplateBindings[$instance] = $bindings; - } - } + $templateName = $templateTag->name; + $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; + + if (isset($existingBindings[$templateName])) { + $existingTypeNode = $existingBindings[$templateName]; + $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); + + if (! $valid) { + return ErrorFactory::createError( + $context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" + ); } - } catch (\Throwable $e) { - // Silently ignore reflection or parsing errors + } + + if ($forceBind || ! isset($existingBindings[$templateName])) { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; } return null; @@ -353,7 +391,6 @@ public static function resolveInheritedTemplates(object $instance, string $targe try { $ref = new \ReflectionClass($actualClassName); $classHierarchy = HierarchyResolver::getClassHierarchy($ref); - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); foreach ($classHierarchy as $hierClass) { @@ -363,17 +400,7 @@ public static function resolveInheritedTemplates(object $instance, string $targe continue; } - $docsToInspect = []; - - $classDoc = $hierClass->getDocComment(); - if ($classDoc !== false) { - $docsToInspect[] = $classDoc; - } - - $traitDocs = SpecialTypeResolver::getClassTraitUseDocs($hierClass->getName()); - foreach ($traitDocs as $tDoc) { - $docsToInspect[] = $tDoc; - } + $docsToInspect = self::collectDocsForClassHierarchyMember($hierClass); foreach ($docsToInspect as $rawDoc) { $classTokens = new TokenIterator($lexer->tokenize($rawDoc)); @@ -386,54 +413,12 @@ public static function resolveInheritedTemplates(object $instance, string $targe } } - // Extract all @extends, @implements, @use and their @template-*, @phpstan-*, @psalm-* variations $inheritedTags = DocblockExtractor::getInheritedTags($classPhpDocNode); foreach ($inheritedTags as $inheritedTag) { $genericTypeNode = $inheritedTag->type; if ($genericTypeNode instanceof GenericTypeNode) { - $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); - - $isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName); - - if (ClassNameValidator::isValid($parentName) && $isHierarchyMember) { - if (! class_exists($parentName) && ! interface_exists($parentName) && ! trait_exists($parentName)) { - continue; - } - - $parentRef = new \ReflectionClass($parentName); - $parentDoc = $parentRef->getDocComment(); - - if ($parentDoc !== false) { - $parentTokens = new TokenIterator($lexer->tokenize($parentDoc)); - $parentPhpDocNode = $phpDocParser->parse($parentTokens); - - $parentTemplateNames = []; - foreach ($parentPhpDocNode->getTags() as $tag) { - if ($tag->value instanceof TemplateTagValueNode) { - $parentTemplateNames[] = $tag->value->name; - } - } - - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - foreach ($parentTemplateNames as $idx => $templateName) { - if (isset($genericTypeNode->genericTypes[$idx])) { - $resolved = self::resolveTypeNodeAst($genericTypeNode->genericTypes[$idx], $hierClass); - - if ($resolved instanceof IdentifierTypeNode && isset($declaredTemplateNames[$resolved->name])) { - continue; - } - - $bindings[$templateName] = $resolved; - } - } - - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new WeakMap(); - } - self::$instanceTemplateBindings[$instance] = $bindings; - } - } + self::bindInheritedGenericTag($genericTypeNode, $hierClass, $declaredTemplateNames, $instance, $actualClassName); } } } @@ -443,6 +428,87 @@ public static function resolveInheritedTemplates(object $instance, string $targe } } + /** + * @param \ReflectionClass $hierClass + * + * @return array + */ + private static function collectDocsForClassHierarchyMember(\ReflectionClass $hierClass): array + { + $docs = []; + + $classDoc = $hierClass->getDocComment(); + if ($classDoc !== false) { + $docs[] = $classDoc; + } + + foreach (SpecialTypeResolver::getClassTraitUseDocs($hierClass->getName()) as $tDoc) { + $docs[] = $tDoc; + } + + return $docs; + } + + /** + * @param \ReflectionClass $hierClass + * @param array $declaredTemplateNames + */ + private static function bindInheritedGenericTag( + GenericTypeNode $genericTypeNode, + \ReflectionClass $hierClass, + array $declaredTemplateNames, + object $instance, + string $actualClassName + ): void { + $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); + $isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName); + + if (! ClassNameValidator::isValid($parentName) || ! $isHierarchyMember) { + return; + } + + if (! class_exists($parentName) && ! interface_exists($parentName) && ! trait_exists($parentName)) { + return; + } + + try { + $parentRef = new \ReflectionClass($parentName); + $parentDoc = $parentRef->getDocComment(); + + if ($parentDoc === false) { + return; + } + + [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); + $parentTokens = new TokenIterator($lexer->tokenize($parentDoc)); + $parentPhpDocNode = $phpDocParser->parse($parentTokens); + + $parentTemplateNames = array_keys(DocblockExtractor::extractTemplates($parentPhpDocNode)); + + if (self::$instanceTemplateBindings === null) { + self::$instanceTemplateBindings = new WeakMap(); + } + + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + + foreach ($parentTemplateNames as $idx => $templateName) { + if (isset($genericTypeNode->genericTypes[$idx])) { + $resolved = self::resolveTypeNodeAst($genericTypeNode->genericTypes[$idx], $hierClass); + + if ($resolved instanceof IdentifierTypeNode && isset($declaredTemplateNames[$resolved->name])) { + continue; + } + + $bindings[$templateName] = $resolved; + } + } + + self::$instanceTemplateBindings[$instance] = $bindings; + } catch (\Throwable $e) { + // Silently ignore reflection errors + } + } + /** * Recursively checks if an existing type node satisfies an expected type node under a given variance modifier. */ @@ -451,112 +517,131 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str $existingStr = (string) $existing; $expectedStr = (string) $expected; - if ($existingStr === $expectedStr) { + if ($existingStr === $expectedStr || $variance === GenericTypeNode::VARIANCE_BIVARIANT || $expectedStr === 'mixed') { return true; } - if ($variance === GenericTypeNode::VARIANCE_BIVARIANT || $expectedStr === 'mixed') { - return true; + if ($expected instanceof UnionTypeNode) { + return self::checkExpectedUnionVariance($existing, $expected, $variance); } - if ($expected instanceof UnionTypeNode) { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - foreach ($expected->types as $unionVariant) { - if (self::checkVariance($existing, $unionVariant, $variance)) { - return true; - } - } + if ($existing instanceof UnionTypeNode) { + return self::checkExistingUnionVariance($existing, $expected, $variance); + } - return false; - } + if ($expected instanceof IntersectionTypeNode) { + return self::checkExpectedIntersectionVariance($existing, $expected, $variance); + } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { - foreach ($expected->types as $unionVariant) { - if (! self::checkVariance($existing, $unionVariant, $variance)) { - return false; - } - } + if ($existing instanceof IntersectionTypeNode) { + return self::checkExistingIntersectionVariance($existing, $expected, $variance); + } - return true; - } + if ($existing instanceof GenericTypeNode && $expected instanceof GenericTypeNode) { + return self::checkNestedGenericVariance($existing, $expected); } - if ($existing instanceof UnionTypeNode) { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - foreach ($existing->types as $existingVariant) { - if (! self::checkVariance($existingVariant, $expected, $variance)) { - return false; - } - } + if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + return self::isSubclass($existingStr, $expectedStr); + } - return true; - } + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { + return self::isSubclass($expectedStr, $existingStr); + } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { - foreach ($existing->types as $existingVariant) { - if (self::checkVariance($existingVariant, $expected, $variance)) { - return true; - } - } + return false; + } - return false; + private static function checkExpectedUnionVariance(TypeNode $existing, UnionTypeNode $expected, string $variance): bool + { + if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + foreach ($expected->types as $unionVariant) { + if (self::checkVariance($existing, $unionVariant, $variance)) { + return true; + } } + + return false; } - if ($expected instanceof IntersectionTypeNode) { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - foreach ($expected->types as $intersectionMember) { - if (! self::checkVariance($existing, $intersectionMember, $variance)) { - return false; - } + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { + foreach ($expected->types as $unionVariant) { + if (! self::checkVariance($existing, $unionVariant, $variance)) { + return false; } - - return true; } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { - foreach ($expected->types as $intersectionMember) { - if (self::checkVariance($existing, $intersectionMember, $variance)) { - return true; - } - } + return true; + } - return false; + return false; + } + + private static function checkExistingUnionVariance(UnionTypeNode $existing, TypeNode $expected, string $variance): bool + { + if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + foreach ($existing->types as $existingVariant) { + if (! self::checkVariance($existingVariant, $expected, $variance)) { + return false; + } } + + return true; } - if ($existing instanceof IntersectionTypeNode) { - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - foreach ($existing->types as $existingMember) { - if (self::checkVariance($existingMember, $expected, $variance)) { - return true; - } + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { + foreach ($existing->types as $existingVariant) { + if (self::checkVariance($existingVariant, $expected, $variance)) { + return true; } - - return false; } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { - foreach ($existing->types as $existingMember) { - if (! self::checkVariance($existingMember, $expected, $variance)) { - return false; - } + return false; + } + + return false; + } + + private static function checkExpectedIntersectionVariance(TypeNode $existing, IntersectionTypeNode $expected, string $variance): bool + { + if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + foreach ($expected->types as $intersectionMember) { + if (! self::checkVariance($existing, $intersectionMember, $variance)) { + return false; } + } - return true; + return true; + } + + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { + foreach ($expected->types as $intersectionMember) { + if (self::checkVariance($existing, $intersectionMember, $variance)) { + return true; + } } + + return true; } - if ($existing instanceof GenericTypeNode && $expected instanceof GenericTypeNode) { - if (! is_a($existing->type->name, $expected->type->name, true)) { - return false; + return false; + } + + private static function checkExistingIntersectionVariance(IntersectionTypeNode $existing, TypeNode $expected, string $variance): bool + { + if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { + foreach ($existing->types as $existingMember) { + if (self::checkVariance($existingMember, $expected, $variance)) { + return true; + } } - foreach ($expected->genericTypes as $idx => $expectedInner) { - $existingInner = $existing->genericTypes[$idx] ?? new IdentifierTypeNode('mixed'); - $innerVariance = $expected->variances[$idx] ?? GenericTypeNode::VARIANCE_INVARIANT; + return false; + } - if (! self::checkVariance($existingInner, $expectedInner, $innerVariance)) { + if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { + foreach ($existing->types as $existingMember) { + if (! self::checkVariance($existingMember, $expected, $variance)) { return false; } } @@ -564,20 +649,31 @@ public static function checkVariance(TypeNode $existing, TypeNode $expected, str return true; } - $isSubclass = function (string $sub, string $super): bool { - if (ClassNameValidator::isValid($sub) && ClassNameValidator::isValid($super) && (class_exists($sub) || interface_exists($sub)) && (class_exists($super) || interface_exists($super))) { - return is_a($sub, $super, true); - } + return false; + } + private static function checkNestedGenericVariance(GenericTypeNode $existing, GenericTypeNode $expected): bool + { + if (! is_a($existing->type->name, $expected->type->name, true)) { return false; - }; + } - if ($variance === GenericTypeNode::VARIANCE_COVARIANT) { - return $isSubclass($existingStr, $expectedStr); + foreach ($expected->genericTypes as $idx => $expectedInner) { + $existingInner = $existing->genericTypes[$idx] ?? new IdentifierTypeNode('mixed'); + $innerVariance = $expected->variances[$idx] ?? GenericTypeNode::VARIANCE_INVARIANT; + + if (! self::checkVariance($existingInner, $expectedInner, $innerVariance)) { + return false; + } } - if ($variance === GenericTypeNode::VARIANCE_CONTRAVARIANT) { - return $isSubclass($expectedStr, $existingStr); + return true; + } + + private static function isSubclass(string $sub, string $super): bool + { + if (ClassNameValidator::isValid($sub) && ClassNameValidator::isValid($super) && (class_exists($sub) || interface_exists($sub)) && (class_exists($super) || interface_exists($super))) { + return is_a($sub, $super, true); } return false; diff --git a/src/Resolver/TemplateSubstitutor.php b/src/Resolver/TemplateSubstitutor.php index 527ee40..906cddb 100644 --- a/src/Resolver/TemplateSubstitutor.php +++ b/src/Resolver/TemplateSubstitutor.php @@ -37,59 +37,19 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $ } if ($node instanceof IdentifierTypeNode) { - if (isset($boundTemplates[$node->name])) { - return $boundTemplates[$node->name]; - } - - if (isset($declaredTemplates[$node->name])) { - $templateTag = $declaredTemplates[$node->name]; - - return $templateTag->default ?? $templateTag->bound ?? new IdentifierTypeNode('mixed'); - } - - return $node; + return self::substituteIdentifier($node, $boundTemplates, $declaredTemplates); } if ($node instanceof CallableTypeNode) { - $parameters = array_map( - fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( - self::substitute($param->type, $boundTemplates, $declaredTemplates), - $param->isReference, - $param->isVariadic, - $param->parameterName, - $param->isOptional - ), - $node->parameters - ); - - $returnType = self::substitute($node->returnType, $boundTemplates, $declaredTemplates); - - return new CallableTypeNode( - $node->identifier, - $parameters, - $returnType, - $node->templateTypes - ); + return self::substituteCallable($node, $boundTemplates, $declaredTemplates); } if ($node instanceof ConditionalTypeNode) { - return new ConditionalTypeNode( - self::substitute($node->subjectType, $boundTemplates, $declaredTemplates), - self::substitute($node->targetType, $boundTemplates, $declaredTemplates), - self::substitute($node->if, $boundTemplates, $declaredTemplates), - self::substitute($node->else, $boundTemplates, $declaredTemplates), - $node->negated - ); + return self::substituteConditional($node, $boundTemplates, $declaredTemplates); } if ($node instanceof ConditionalTypeForParameterNode) { - return new ConditionalTypeForParameterNode( - $node->parameterName, - self::substitute($node->targetType, $boundTemplates, $declaredTemplates), - self::substitute($node->if, $boundTemplates, $declaredTemplates), - self::substitute($node->else, $boundTemplates, $declaredTemplates), - $node->negated - ); + return self::substituteParameterConditional($node, $boundTemplates, $declaredTemplates); } if ($node instanceof ArrayTypeNode) { @@ -97,17 +57,7 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $ } if ($node instanceof GenericTypeNode) { - $type = self::substitute($node->type, $boundTemplates, $declaredTemplates); - $genericTypes = array_map( - fn ($t) => self::substitute($t, $boundTemplates, $declaredTemplates), - $node->genericTypes - ); - - return new GenericTypeNode( - $type instanceof IdentifierTypeNode ? $type : $node->type, - $genericTypes, - $node->variances - ); + return self::substituteGeneric($node, $boundTemplates, $declaredTemplates); } if ($node instanceof NullableTypeNode) { @@ -129,25 +79,160 @@ public static function substitute(TypeNode $node, array $boundTemplates, array $ } if ($node instanceof ArrayShapeNode) { - foreach ($node->items as $item) { - $item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates); - } - if ($node->unsealedType !== null) { - if ($node->unsealedType->keyType !== null) { - $node->unsealedType->keyType = self::substitute($node->unsealedType->keyType, $boundTemplates, $declaredTemplates); - } - $node->unsealedType->valueType = self::substitute($node->unsealedType->valueType, $boundTemplates, $declaredTemplates); - } - - return $node; + return self::substituteArrayShape($node, $boundTemplates, $declaredTemplates); } if ($node instanceof ObjectShapeNode) { - foreach ($node->items as $item) { - $item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates); + return self::substituteObjectShape($node, $boundTemplates, $declaredTemplates); + } + + return $node; + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteIdentifier( + IdentifierTypeNode $node, + array $boundTemplates, + array $declaredTemplates + ): TypeNode { + if (isset($boundTemplates[$node->name])) { + return $boundTemplates[$node->name]; + } + + if (isset($declaredTemplates[$node->name])) { + $templateTag = $declaredTemplates[$node->name]; + + return $templateTag->default ?? $templateTag->bound ?? new IdentifierTypeNode('mixed'); + } + + return $node; + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteCallable( + CallableTypeNode $node, + array $boundTemplates, + array $declaredTemplates + ): CallableTypeNode { + $parameters = array_map( + fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode( + self::substitute($param->type, $boundTemplates, $declaredTemplates), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ), + $node->parameters + ); + + $returnType = self::substitute($node->returnType, $boundTemplates, $declaredTemplates); + + return new CallableTypeNode( + $node->identifier, + $parameters, + $returnType, + $node->templateTypes + ); + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteConditional( + ConditionalTypeNode $node, + array $boundTemplates, + array $declaredTemplates + ): ConditionalTypeNode { + return new ConditionalTypeNode( + self::substitute($node->subjectType, $boundTemplates, $declaredTemplates), + self::substitute($node->targetType, $boundTemplates, $declaredTemplates), + self::substitute($node->if, $boundTemplates, $declaredTemplates), + self::substitute($node->else, $boundTemplates, $declaredTemplates), + $node->negated + ); + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteParameterConditional( + ConditionalTypeForParameterNode $node, + array $boundTemplates, + array $declaredTemplates + ): ConditionalTypeForParameterNode { + return new ConditionalTypeForParameterNode( + $node->parameterName, + self::substitute($node->targetType, $boundTemplates, $declaredTemplates), + self::substitute($node->if, $boundTemplates, $declaredTemplates), + self::substitute($node->else, $boundTemplates, $declaredTemplates), + $node->negated + ); + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteGeneric( + GenericTypeNode $node, + array $boundTemplates, + array $declaredTemplates + ): GenericTypeNode { + $type = self::substitute($node->type, $boundTemplates, $declaredTemplates); + $genericTypes = array_map( + fn ($t) => self::substitute($t, $boundTemplates, $declaredTemplates), + $node->genericTypes + ); + + return new GenericTypeNode( + $type instanceof IdentifierTypeNode ? $type : $node->type, + $genericTypes, + $node->variances + ); + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteArrayShape( + ArrayShapeNode $node, + array $boundTemplates, + array $declaredTemplates + ): ArrayShapeNode { + foreach ($node->items as $item) { + $item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates); + } + + if ($node->unsealedType !== null) { + if ($node->unsealedType->keyType !== null) { + $node->unsealedType->keyType = self::substitute($node->unsealedType->keyType, $boundTemplates, $declaredTemplates); } + $node->unsealedType->valueType = self::substitute($node->unsealedType->valueType, $boundTemplates, $declaredTemplates); + } - return $node; + return $node; + } + + /** + * @param array $boundTemplates + * @param array $declaredTemplates + */ + private static function substituteObjectShape( + ObjectShapeNode $node, + array $boundTemplates, + array $declaredTemplates + ): ObjectShapeNode { + foreach ($node->items as $item) { + $item->valueType = self::substitute($item->valueType, $boundTemplates, $declaredTemplates); } return $node; diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index 6bb9fa6..d826846 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -93,7 +93,11 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string return function (...$args) use ($callable, $typeNode, $registry, $prefix) { self::validateCallbackArguments($typeNode, $args, $prefix, $registry); - $result = $callable(...$args); + try { + $result = $callable(...$args); + } catch (\TypeError $e) { + throw ErrorFactory::prepareException($e); + } $err = $registry->validate($result, $typeNode->returnType, "$prefix return value"); if ($err !== null) { diff --git a/tests/Fixtures/Callables/GenericCallableService.php b/tests/Fixtures/Callables/GenericCallableService.php index c8dc613..543008e 100644 --- a/tests/Fixtures/Callables/GenericCallableService.php +++ b/tests/Fixtures/Callables/GenericCallableService.php @@ -37,4 +37,69 @@ public function formatAnimal(callable $formatter, Animal $animal): string { return $formatter($animal); } + + /** + * Higher-order generic array transformer + * + * @template K of array-key + * @template V + * @template V2 + * + * @param callable(V): V2 $callback + * @param array $array + * + * @return array + */ + public function mapArray(callable $callback, array $array): array + { + $result = []; + foreach ($array as $key => $value) { + $result[$key] = $callback($value); + } + + return $result; + } + + /** + * Higher-order sequential list transformer + * + * @template T + * @template R + * + * @param callable(T): R $callback + * @param list $items + * + * @return list + */ + public function mapList(callable $callback, array $items): array + { + $result = []; + foreach ($items as $item) { + $result[] = $callback($item); + } + + return $result; + } + + /** + * Higher-order transformer passing both Key and Value to callback + * + * @template K of array-key + * @template V + * @template V2 + * + * @param callable(K, V): V2 $callback + * @param array $map + * + * @return array + */ + public function mapWithKey(callable $callback, array $map): array + { + $result = []; + foreach ($map as $key => $value) { + $result[$key] = $callback($key, $value); + } + + return $result; + } } diff --git a/tests/Fixtures/Collections/ConcreteFileCollection.php b/tests/Fixtures/Collections/ConcreteFileCollection.php index b383f79..831dd97 100644 --- a/tests/Fixtures/Collections/ConcreteFileCollection.php +++ b/tests/Fixtures/Collections/ConcreteFileCollection.php @@ -24,4 +24,4 @@ public function getIterator(): Traversable { return new ArrayIterator($this->files); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Collections/PluginConfiguration.php b/tests/Fixtures/Collections/PluginConfiguration.php index b86e200..c6c7593 100644 --- a/tests/Fixtures/Collections/PluginConfiguration.php +++ b/tests/Fixtures/Collections/PluginConfiguration.php @@ -65,4 +65,4 @@ public function processGenericTraversable(Traversable $items): array return $collected; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Domain/User.php b/tests/Fixtures/Domain/User.php index d21173e..f14b7cb 100644 --- a/tests/Fixtures/Domain/User.php +++ b/tests/Fixtures/Domain/User.php @@ -11,4 +11,4 @@ public function __construct( public int $id = 1 ) { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Types/HookedUser.php b/tests/Fixtures/Types/HookedUser.php index f3a0590..734fdfe 100644 --- a/tests/Fixtures/Types/HookedUser.php +++ b/tests/Fixtures/Types/HookedUser.php @@ -30,4 +30,4 @@ public function updateProfile(int $newId, string $newUsername): void $this->id = $newId; $this->username = $newUsername; } -} \ No newline at end of file +} diff --git a/tests/Internal/ContractParserTest.php b/tests/Internal/ContractParserTest.php index 931bb92..9014470 100644 --- a/tests/Internal/ContractParserTest.php +++ b/tests/Internal/ContractParserTest.php @@ -2,41 +2,326 @@ declare(strict_types=1); +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; +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\UnionTypeNode; use TypePHP\Contract\ContractParser; +use TypePHP\Internal\Config; +use TypePHP\Tests\Fixtures\IgnoreTags\IgnoredMethod; +use TypePHP\Tests\Fixtures\Services\ChildMagicMethodService; use TypePHP\Tests\Fixtures\Services\UserService; +use TypePHP\Tests\Fixtures\Types\ChildMagicPropertyFixture; use TypePHP\Tests\Fixtures\Types\ConfiguredProperty; +use TypePHP\Tests\Fixtures\Types\HookedInterfaceImplementation; +use TypePHP\Tests\Fixtures\Types\MagicMethodFixture; +use TypePHP\Tests\Fixtures\Types\MagicPropertyFixture; +use TypePHP\Tests\Fixtures\Types\NestedAliasService; use TypePHP\Tests\Fixtures\Types\NonCpmStrings; describe('ContractParser Unit Tests', function () { - test('parses function/method contracts and caches results', function () { - $target = UserService::class . '::find'; + beforeEach(function () { + Config::reset(); + ContractParser::reset(); + }); + + afterEach(function () { + Config::reset(); + ContractParser::reset(); + }); + + describe('Function and Method Parsing (parse)', function () { + test('parses class method contracts and caches results', function () { + $target = UserService::class . '::find'; + + $contract1 = ContractParser::parse($target); + $contract2 = ContractParser::parse($target); + + expect($contract1)->toBeArray() + ->and($contract1['types'])->toHaveKey('id') + ->and($contract1['return'])->not()->toBeNull() + ->and($contract1)->toBe($contract2) + ; + }); + + test('parses standalone global/namespaced functions', function () { + $target = 'TypePHP\Tests\Fixtures\Functions\calculateDiscount'; + + $contract = ContractParser::parse($target); - $contract1 = ContractParser::parse($target); - $contract2 = ContractParser::parse($target); + expect($contract['types'])->toHaveKey('price') + ->and($contract['types'])->toHaveKey('percentage') + ->and((string) $contract['types']['price'])->toBe('positive-int') + ->and($contract['return'])->not()->toBeNull() + ->and((string) $contract['return'])->toBe('positive-int') + ; + }); - expect($contract1)->toBeArray() - ->and($contract1['types'])->toHaveKey('id') - ->and($contract1)->toBe($contract2) // Exact cached reference - ; + test('returns empty contract array for non-existent classes or functions', function () { + $contract = ContractParser::parse('NonExistentClass12345::method'); + + expect($contract['types'])->toBeEmpty() + ->and($contract['templates'])->toBeEmpty() + ->and($contract['return'])->toBeNull() + ->and($contract['aliases'])->toBeEmpty() + ; + }); + + test('returns class-level templates and aliases when class has no requested method', function () { + $target = NestedAliasService::class . '::nonExistentMethod'; + + $contract = ContractParser::parse($target); + + expect($contract['types'])->toBeEmpty() + ->and($contract['aliases'])->toHaveKey('LocalId') + ->and($contract['aliases'])->toHaveKey('LocalRecordShape') + ; + }); + + test('falls back to property @var docblock for constructor property promotion', function () { + $target = NonCpmStrings::class . '::__construct'; + $contract = ContractParser::parse($target); + + expect($contract['types'])->toHaveKey('strings') + ->and($contract['types']['strings'])->toBeInstanceOf(ArrayTypeNode::class) + ; + }); }); - test('parses property @var docblocks using parseProperty', function () { - $typeNode = ContractParser::parseProperty(ConfiguredProperty::class, 'numbers'); + describe('Property Contract Parsing (parseProperty)', function () { + test('parses instance and static property @var docblocks', function () { + $instanceProp = ContractParser::parseProperty(ConfiguredProperty::class, 'numbers'); + expect($instanceProp)->toBeInstanceOf(ArrayTypeNode::class); + + $staticProp = ContractParser::parseProperty(ConfiguredProperty::class, 'staticTitle'); + expect($staticProp)->toBeInstanceOf(IdentifierTypeNode::class) + ->and($staticProp->name)->toBe('string') + ; + }); + + test('parses interface property docblocks', function () { + if (PHP_VERSION_ID < 80400) { + expect(true)->toBeTrue(); - expect($typeNode)->toBeInstanceOf(ArrayTypeNode::class) - ->and($typeNode->type)->toBeInstanceOf(IdentifierTypeNode::class) - ->and($typeNode->type->name)->toBe('int') - ; + return; + } + + $readOnlyProp = ContractParser::parseProperty(HookedInterfaceImplementation::class, 'readOnlyProp'); + + expect($readOnlyProp)->not()->toBeNull() + ->and((string) $readOnlyProp)->toBe('positive-int') + ; + }); + + test('parses class-level magic @property docblocks', function () { + $magicScore = ContractParser::parseProperty(MagicPropertyFixture::class, 'magicScore'); + expect((string) $magicScore)->toBe('positive-int'); + + $magicName = ContractParser::parseProperty(MagicPropertyFixture::class, 'magicName'); + expect((string) $magicName)->toBe('non-empty-string'); + + $magicTags = ContractParser::parseProperty(MagicPropertyFixture::class, 'magicTags'); + expect((string) $magicTags)->toBe('list'); + }); + + test('inherits magic @property docblocks across inheritance hierarchy', function () { + $inheritedRole = ContractParser::parseProperty(ChildMagicPropertyFixture::class, 'magicRole'); + + expect($inheritedRole)->not()->toBeNull() + ->and((string) $inheritedRole)->toContain('admin') + ; + }); + + test('returns null for un-annotated properties or non-existent classes', function () { + expect(ContractParser::parseProperty('NonExistentClass123', 'prop'))->toBeNull(); + expect(ContractParser::parseProperty(ConfiguredProperty::class, 'nonExistentProperty'))->toBeNull(); + }); + + test('returns null for properties marked with @typephp-ignore', function () { + $ignored = ContractParser::parseProperty(IgnoredMethod::class, 'ignoredProperty'); + + expect($ignored)->toBeNull(); + }); }); - test('falls back to property @var docblock for constructor property promotion', function () { - $target = NonCpmStrings::class . '::__construct'; - $contract = ContractParser::parse($target); + describe('Magic Method Parsing (parseMagicMethod)', function () { + test('parses dynamic @method annotations with variadics and optional parameters', function () { + $method = ContractParser::parseMagicMethod(MagicMethodFixture::class, 'processId'); + + expect($method)->not()->toBeNull() + ->and((string) $method['return'])->toBe('positive-int') + ->and($method['parameters'])->toHaveCount(2) + ->and($method['parameters'][0]['name'])->toBe('id') + ->and((string) $method['parameters'][0]['type'])->toBe('positive-int') + ; + + $variadicMethod = ContractParser::parseMagicMethod(MagicMethodFixture::class, 'fetchList'); + expect($variadicMethod['parameters'][0]['isVariadic'])->toBeTrue(); + }); + + test('inherits magic @method annotations from parent classes, interfaces, and traits', function () { + $parentMethod = ContractParser::parseMagicMethod(ChildMagicMethodService::class, 'parentMethod'); + expect($parentMethod)->not()->toBeNull(); + + $interfaceMethod = ContractParser::parseMagicMethod(ChildMagicMethodService::class, 'interfaceMethod'); + expect($interfaceMethod)->not()->toBeNull(); + + $traitMethod = ContractParser::parseMagicMethod(ChildMagicMethodService::class, 'traitMethod'); + expect($traitMethod)->not()->toBeNull(); + }); + + test('returns null for non-existent magic methods or non-existent classes', function () { + expect(ContractParser::parseMagicMethod('NonExistentClass123', 'method'))->toBeNull(); + expect(ContractParser::parseMagicMethod(MagicMethodFixture::class, 'nonExistentMagicMethod'))->toBeNull(); + }); + }); + + describe('Class Aliases (parseClassAliases)', function () { + test('parses and returns all local type aliases for a class', function () { + $aliases = ContractParser::parseClassAliases(NestedAliasService::class); + + expect($aliases)->toHaveKey('LocalId') + ->and($aliases)->toHaveKey('LocalStatus') + ->and($aliases)->toHaveKey('LocalRecordShape') + ->and((string) $aliases['LocalId'])->toBe('positive-int') + ; + }); + + test('returns empty array for non-existent classes', function () { + expect(ContractParser::parseClassAliases('NonExistentClass123'))->toBe([]); + }); + }); + + describe('AST Type Alias Substitution (substituteAliases)', function () { + beforeEach(function () { + $this->aliases = [ + 'UserId' => new IdentifierTypeNode('positive-int'), + 'UserName' => new IdentifierTypeNode('non-empty-string'), + 'UserRole' => new IdentifierTypeNode("'admin'|'user'"), + ]; + }); + + test('substitutes aliases in IdentifierTypeNode', function () { + $node = new IdentifierTypeNode('UserId'); + $result = ContractParser::substituteAliases($node, $this->aliases); + + expect((string) $result)->toBe('positive-int'); + + $unaliased = new IdentifierTypeNode('string'); + expect(ContractParser::substituteAliases($unaliased, $this->aliases))->toBe($unaliased); + }); + + test('substitutes parameter and return aliases in CallableTypeNode', function () { + $callableNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('UserId'), false, false, 'id', false)], + new IdentifierTypeNode('UserName'), + [] + ); + + $result = ContractParser::substituteAliases($callableNode, $this->aliases); + + expect($result)->toBeInstanceOf(CallableTypeNode::class) + ->and((string) $result)->toContain('positive-int') + ->and((string) $result)->toContain('non-empty-string') + ; + }); + + test('substitutes target and offset aliases in OffsetAccessTypeNode', function () { + $offsetNode = new OffsetAccessTypeNode(new IdentifierTypeNode('UserId'), new IdentifierTypeNode('UserName')); + $result = ContractParser::substituteAliases($offsetNode, $this->aliases); + + expect($result)->toBeInstanceOf(OffsetAccessTypeNode::class) + ->and((string) $result->type)->toBe('positive-int') + ->and((string) $result->offset)->toBe('non-empty-string') + ; + }); + + test('substitutes inner type aliases in ArrayTypeNode (UserId[] -> positive-int[])', function () { + $arrNode = new ArrayTypeNode(new IdentifierTypeNode('UserId')); + $result = ContractParser::substituteAliases($arrNode, $this->aliases); + + expect($result)->toBeInstanceOf(ArrayTypeNode::class) + ->and((string) $result)->toBe('positive-int[]') + ; + }); + + test('substitutes base and generic argument aliases in GenericTypeNode (Collection)', function () { + $genericNode = new GenericTypeNode(new IdentifierTypeNode('Collection'), [new IdentifierTypeNode('UserId')]); + $result = ContractParser::substituteAliases($genericNode, $this->aliases); + + expect($result)->toBeInstanceOf(GenericTypeNode::class) + ->and((string) $result)->toContain('positive-int') + ; + }); + + test('substitutes inner type aliases in NullableTypeNode (?UserId -> ?positive-int)', function () { + $nullableNode = new NullableTypeNode(new IdentifierTypeNode('UserId')); + $result = ContractParser::substituteAliases($nullableNode, $this->aliases); + + expect($result)->toBeInstanceOf(NullableTypeNode::class) + ->and((string) $result)->toBe('?positive-int') + ; + }); + + test('substitutes member type aliases in UnionTypeNode (UserId|UserName)', function () { + $unionNode = new UnionTypeNode([new IdentifierTypeNode('UserId'), new IdentifierTypeNode('UserName')]); + $result = ContractParser::substituteAliases($unionNode, $this->aliases); + + expect($result)->toBeInstanceOf(UnionTypeNode::class) + ->and((string) $result)->toContain('positive-int') + ->and((string) $result)->toContain('non-empty-string') + ; + }); + + test('substitutes member type aliases in IntersectionTypeNode (UserId&UserName)', function () { + $intersectionNode = new IntersectionTypeNode([new IdentifierTypeNode('UserId'), new IdentifierTypeNode('UserName')]); + $result = ContractParser::substituteAliases($intersectionNode, $this->aliases); + + expect($result)->toBeInstanceOf(IntersectionTypeNode::class) + ->and((string) $result)->toContain('positive-int') + ->and((string) $result)->toContain('non-empty-string') + ; + }); + + test('substitutes field and unsealed type aliases in ArrayShapeNode', function () { + $unsealed = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('UserName'), new IdentifierTypeNode('UserId')); + $shapeNode = ArrayShapeNode::createUnsealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('UserId')), + ], $unsealed); + + $result = ContractParser::substituteAliases($shapeNode, $this->aliases); + + expect($result)->toBeInstanceOf(ArrayShapeNode::class) + ->and((string) $result->items[0]->valueType)->toBe('positive-int') + ->and((string) $result->unsealedType?->keyType)->toBe('positive-int') + ->and((string) $result->unsealedType?->valueType)->toBe('non-empty-string') + ; + }); + + test('substitutes property value aliases in ObjectShapeNode', function () { + $objShapeNode = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('UserId')), + new ObjectShapeItemNode(new IdentifierTypeNode('name'), false, new IdentifierTypeNode('UserName')), + ]); + + $result = ContractParser::substituteAliases($objShapeNode, $this->aliases); - expect($contract['types'])->toHaveKey('strings') - ->and($contract['types']['strings'])->toBeInstanceOf(ArrayTypeNode::class) - ; + expect($result)->toBeInstanceOf(ObjectShapeNode::class) + ->and((string) $result->items[0]->valueType)->toBe('positive-int') + ->and((string) $result->items[1]->valueType)->toBe('non-empty-string') + ; + }); }); }); diff --git a/tests/Resolver/SpecialTypeResolverTest.php b/tests/Resolver/SpecialTypeResolverTest.php index 6be411e..a7f6aed 100644 --- a/tests/Resolver/SpecialTypeResolverTest.php +++ b/tests/Resolver/SpecialTypeResolverTest.php @@ -2,88 +2,297 @@ declare(strict_types=1); +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; +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\UnionTypeNode; +use TypePHP\Internal\ErrorMessage; use TypePHP\Resolver\SpecialTypeResolver; +use TypePHP\Tests\Fixtures\Generics\InlineTraitUseService; use TypePHP\Tests\Fixtures\Services\BaseService; use TypePHP\Tests\Fixtures\Services\UserService; +use TypePHP\Tests\Fixtures\Types\ConstKeyContainer; +use TypePHP\Tests\Fixtures\Types\OffsetAccessContainer; use TypePHP\Tests\Fixtures\Types\UserApi; describe('SpecialTypeResolver Unit Tests', function () { - test('resolves self to declaring class FQCN', function () { - $ref = new ReflectionMethod(UserService::class, 'find'); - $node = new IdentifierTypeNode('self'); + describe('checkThisIdentity', function () { + test('accepts identical $this instance when return type is $this', function () { + $service = new UserService(); + $thisNode = new ThisTypeNode(); - $resolved = SpecialTypeResolver::resolve($node, $ref); + $err = SpecialTypeResolver::checkThisIdentity($thisNode, $service, $service, 'testFunc'); - expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class) - ->and($resolved->name)->toBe(UserService::class) - ; + expect($err)->toBeNull(); + }); + + test('returns ErrorMessage when returned object is not the same $this instance', function () { + $service1 = new UserService(); + $service2 = new UserService(); + $thisNode = new ThisTypeNode(); + + $err = SpecialTypeResolver::checkThisIdentity($thisNode, $service2, $service1, 'testFunc'); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be $this instance') + ; + }); + + test('ignores non-this return types cleanly', function () { + $service = new UserService(); + $intNode = new IdentifierTypeNode('int'); + + $err = SpecialTypeResolver::checkThisIdentity($intNode, 100, $service, 'testFunc'); + + expect($err)->toBeNull(); + }); }); - test('resolves parent to parent class FQCN', function () { - $ref = new ReflectionMethod(UserService::class, 'find'); - $node = new IdentifierTypeNode('parent'); + describe('resolve: Special Identifier Keywords (self, parent, static, $this)', function () { + test('resolves self to declaring class FQCN', function () { + $ref = new ReflectionMethod(UserService::class, 'find'); + $node = new IdentifierTypeNode('self'); + + $resolved = SpecialTypeResolver::resolve($node, $ref); + + expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class) + ->and($resolved->name)->toBe(UserService::class) + ; + }); + + test('resolves parent to parent class FQCN', function () { + $ref = new ReflectionMethod(UserService::class, 'find'); + $node = new IdentifierTypeNode('parent'); + + $resolved = SpecialTypeResolver::resolve($node, $ref); - $resolved = SpecialTypeResolver::resolve($node, $ref); + expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class) + ->and($resolved->name)->toBe(BaseService::class) + ; + }); - expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class) - ->and($resolved->name)->toBe(BaseService::class) - ; + test('resolves $this and static to concrete calling instance class when thisObj is provided', function () { + $service = new UserService(); + $ref = new ReflectionMethod(UserService::class, 'find'); + + $thisNode = new IdentifierTypeNode('$this'); + $staticNode = new IdentifierTypeNode('static'); + + expect(SpecialTypeResolver::resolve($thisNode, $ref, $service)->name)->toBe(UserService::class) + ->and(SpecialTypeResolver::resolve($staticNode, $ref, $service)->name)->toBe(UserService::class) + ; + }); + + test('resolves static to calling class in string method context (Class::method)', function () { + $staticNode = new IdentifierTypeNode('static'); + $context = UserService::class . '::find'; + + $resolved = SpecialTypeResolver::resolve($staticNode, $context); + + expect($resolved->name)->toBe(UserService::class); + }); + + test('leaves built-in scalar and pseudo-type keywords untouched', function () { + $ref = new ReflectionMethod(UserService::class, 'find'); + $primitives = ['int', 'string', 'bool', 'float', 'array', 'mixed', 'void', 'positive-int', 'non-empty-string']; + + foreach ($primitives as $primitive) { + $node = new IdentifierTypeNode($primitive); + $resolved = SpecialTypeResolver::resolve($node, $ref); + + expect($resolved->name)->toBe($primitive); + } + }); }); - test('preserves $this and static keyword nodes', function () { - $ref = new ReflectionMethod(UserService::class, 'find'); + describe('resolve: Class Constants (ConstTypeNode)', function () { + test('resolves self::CONST and parent::CONST in ConstTypeNode', function () { + $ref = new ReflectionMethod(ConstKeyContainer::class, 'process'); - $thisNode = new IdentifierTypeNode('$this'); - $staticNode = new IdentifierTypeNode('static'); + $selfConst = new ConstTypeNode(new ConstFetchNode('self', 'KEY_ID')); + $resolvedSelf = SpecialTypeResolver::resolve($selfConst, $ref); + expect($resolvedSelf->constExpr->className)->toBe(ConstKeyContainer::class); - expect(SpecialTypeResolver::resolve($thisNode, $ref)->name)->toBe('$this') - ->and(SpecialTypeResolver::resolve($staticNode, $ref)->name)->toBe('static') - ; + $parentConst = new ConstTypeNode(new ConstFetchNode('parent', 'SOME_CONST')); + $resolvedParent = SpecialTypeResolver::resolve($parentConst, $ref); + expect($resolvedParent)->toBeInstanceOf(ConstTypeNode::class); + }); }); - test('resolves imported class names using Reflection file context', function () { - $ref = new ReflectionMethod(UserApi::class, 'saveUser'); - $node = new IdentifierTypeNode('LocalUserShape'); + describe('resolve: Offset Access (T[K])', function () { + test('resolves offset on array shape and class constant array', function () { + $ref = new ReflectionMethod(OffsetAccessContainer::class, 'setUserId'); + + // 1. Array Shape Offset: array{id: positive-int}['id'] -> positive-int + $shapeNode = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('positive-int')), + ]); + $offsetNode = new OffsetAccessTypeNode($shapeNode, new IdentifierTypeNode('id')); - $resolved = SpecialTypeResolver::resolve($node, $ref); + $resolved = SpecialTypeResolver::resolve($offsetNode, $ref); + expect((string) $resolved)->toBe('positive-int'); - expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class); + // 2. Class Constant Offset: OffsetAccessContainer::CONFIG_MAP['mysql'] -> literal 'PDO\MySQL\Driver' + $constArray = new ConstTypeNode(new ConstFetchNode(OffsetAccessContainer::class, 'CONFIG_MAP')); + $constOffset = new OffsetAccessTypeNode($constArray, new IdentifierTypeNode('mysql')); + + $resolvedConst = SpecialTypeResolver::resolve($constOffset, $ref); + expect($resolvedConst)->toBeInstanceOf(ConstTypeNode::class); + }); }); - test('normalizes backslashes to forward slashes in file metadata seeding and lookups', function () { - $windowsPath = 'C:\\project\\app\\Services\\UserService.php'; - SpecialTypeResolver::seedFileMetadata($windowsPath, 'App\\Services', ['User' => 'App\\Models\\User']); + describe('resolve: Array Shapes with Constant Keys', function () { + test('resolves self::KEY_ID constant key inside ArrayShapeNode', function () { + $ref = new ReflectionMethod(ConstKeyContainer::class, 'process'); + + $shapeNode = ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + new ConstFetchNode('self', 'KEY_ID'), + false, + new IdentifierTypeNode('positive-int') + ), + ]); - $forwardPath = 'C:/project/app/Services/UserService.php'; + $resolved = SpecialTypeResolver::resolve($shapeNode, $ref); - expect(SpecialTypeResolver::getNamespaceFromFile($forwardPath))->toBe('App\\Services') - ->and(SpecialTypeResolver::getUseImportsFromFile($forwardPath))->toHaveKey('User') - ; + expect($resolved)->toBeInstanceOf(ArrayShapeNode::class) + ->and($resolved->items[0]->keyName->value)->toBe('user_id') + ; + }); }); - test('leaves built-in scalar and pseudo-type keywords untouched', function () { - $ref = new ReflectionMethod(UserService::class, 'find'); + describe('resolve: All Complex AST Branches', function () { + test('recursively resolves Callables, Conditionals, Generics, Unions, Intersections, and Shapes', function () { + $ref = new ReflectionMethod(UserService::class, 'find'); - $primitives = ['int', 'string', 'bool', 'float', 'array', 'mixed', 'void', 'positive-int', 'non-empty-string']; + $genericNode = new GenericTypeNode(new IdentifierTypeNode('Collection'), [new IdentifierTypeNode('self')]); + $resGeneric = SpecialTypeResolver::resolve($genericNode, $ref); + expect($resGeneric->genericTypes[0]->name)->toBe(UserService::class); - foreach ($primitives as $primitive) { - $node = new IdentifierTypeNode($primitive); - $resolved = SpecialTypeResolver::resolve($node, $ref); + $callableNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('self'), false, false, 'item', false)], + new IdentifierTypeNode('self'), + [] + ); + $resCallable = SpecialTypeResolver::resolve($callableNode, $ref); + expect($resCallable->returnType->name)->toBe(UserService::class); + + $conditionalNode = new ConditionalTypeNode( + new IdentifierTypeNode('self'), + new IdentifierTypeNode('self'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false + ); + expect(SpecialTypeResolver::resolve($conditionalNode, $ref))->toBeInstanceOf(ConditionalTypeNode::class); + + $paramConditional = new ConditionalTypeForParameterNode( + '$flag', + new IdentifierTypeNode('true'), + new IdentifierTypeNode('int'), + new IdentifierTypeNode('string'), + false + ); + expect(SpecialTypeResolver::resolve($paramConditional, $ref))->toBeInstanceOf(ConditionalTypeForParameterNode::class); + + $nullable = new NullableTypeNode(new IdentifierTypeNode('self')); + expect(SpecialTypeResolver::resolve($nullable, $ref)->type->name)->toBe(UserService::class); + + $array = new ArrayTypeNode(new IdentifierTypeNode('self')); + expect(SpecialTypeResolver::resolve($array, $ref)->type->name)->toBe(UserService::class); + + $union = new UnionTypeNode([new IdentifierTypeNode('self'), new IdentifierTypeNode('int')]); + expect(SpecialTypeResolver::resolve($union, $ref)->types[0]->name)->toBe(UserService::class); + + $intersection = new IntersectionTypeNode([new IdentifierTypeNode('self'), new IdentifierTypeNode('Countable')]); + expect(SpecialTypeResolver::resolve($intersection, $ref)->types[0]->name)->toBe(UserService::class); + + $objShape = new ObjectShapeNode([new ObjectShapeItemNode(new IdentifierTypeNode('id'), false, new IdentifierTypeNode('self'))]); + expect(SpecialTypeResolver::resolve($objShape, $ref)->items[0]->valueType->name)->toBe(UserService::class); + }); + }); + + describe('resolveForFile: File Context Resolution', function () { + test('resolves imported class names using file path context', function () { + $filePath = (new ReflectionClass(UserApi::class))->getFileName(); + expect($filePath)->not()->toBeFalse(); + + $node = new IdentifierTypeNode('GlobalTypes'); + $resolved = SpecialTypeResolver::resolveForFile($node, (string) $filePath); - expect($resolved->name)->toBe($primitive); - } + expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class) + ->and($resolved->name)->toBe('TypePHP\Tests\Fixtures\Types\GlobalTypes') + ; + }); + + test('resolves complex AST branches with resolveForFile', function () { + $filePath = (new ReflectionClass(UserApi::class))->getFileName(); + expect($filePath)->not()->toBeFalse(); + + $generic = new GenericTypeNode(new IdentifierTypeNode('GlobalTypes'), [new IdentifierTypeNode('GlobalTypes')]); + $resGeneric = SpecialTypeResolver::resolveForFile($generic, (string) $filePath); + expect($resGeneric->type->name)->toBe('TypePHP\Tests\Fixtures\Types\GlobalTypes'); + + $nullable = new NullableTypeNode(new IdentifierTypeNode('GlobalTypes')); + expect(SpecialTypeResolver::resolveForFile($nullable, (string) $filePath)->type->name)->toBe('TypePHP\Tests\Fixtures\Types\GlobalTypes'); + + $union = new UnionTypeNode([new IdentifierTypeNode('GlobalTypes'), new IdentifierTypeNode('int')]); + expect(SpecialTypeResolver::resolveForFile($union, (string) $filePath)->types[0]->name)->toBe('TypePHP\Tests\Fixtures\Types\GlobalTypes'); + }); }); - test('resolves class names for file context using resolveForFile', function () { - $filePath = (new ReflectionClass(UserApi::class))->getFileName(); - expect($filePath)->not()->toBeFalse(); + describe('Metadata Seeding & Trait DocBlocks', function () { + test('seeds and extracts file namespace and use imports with slash normalization', function () { + $windowsPath = 'C:\\project\\app\\Services\\UserService.php'; + SpecialTypeResolver::seedFileMetadata($windowsPath, 'App\\Services', ['User' => 'App\\Models\\User'], [ + 'App\\Services\\UserService' => ['/** @use LoggerTrait */'], + ]); + + $forwardPath = 'C:/project/app/Services/UserService.php'; + + expect(SpecialTypeResolver::getNamespaceFromFile($forwardPath))->toBe('App\\Services') + ->and(SpecialTypeResolver::getUseImportsFromFile($forwardPath))->toHaveKey('User') + ->and(SpecialTypeResolver::getClassTraitUseDocs('App\\Services\\UserService'))->toContain('/** @use LoggerTrait */') + ; + }); + + test('extracts inline trait use docblocks from unseeded class file directly', function () { + $docs = SpecialTypeResolver::getClassTraitUseDocs(InlineTraitUseService::class); + + expect($docs)->toHaveCount(1) + ->and($docs[0])->toContain('GenericItemLoggerTrait') + ; + }); + }); + + describe('resolveFqcn & resolveFqcnForFile', function () { + test('un-prefixes leading backslashes on FQCNs', function () { + $ref = new ReflectionMethod(UserService::class, 'find'); + + expect(SpecialTypeResolver::resolveFqcn('\App\Models\User', $ref))->toBe('App\Models\User'); + expect(SpecialTypeResolver::resolveFqcnForFile('\App\Models\User', 'some_file.php'))->toBe('App\Models\User'); + }); - $node = new IdentifierTypeNode('GlobalTypes'); - $resolved = SpecialTypeResolver::resolveForFile($node, (string) $filePath); + test('returns built-in type keywords untouched', function () { + $ref = new ReflectionMethod(UserService::class, 'find'); - expect($resolved)->toBeInstanceOf(IdentifierTypeNode::class) - ->and($resolved->name)->toBe('TypePHP\Tests\Fixtures\Types\GlobalTypes') - ; + expect(SpecialTypeResolver::resolveFqcn('positive-int', $ref))->toBe('positive-int'); + expect(SpecialTypeResolver::resolveFqcnForFile('non-empty-string', 'some_file.php'))->toBe('non-empty-string'); + }); }); }); diff --git a/tests/Resolver/TemplateManagerTest.php b/tests/Resolver/TemplateManagerTest.php index 5b1f03a..014af48 100644 --- a/tests/Resolver/TemplateManagerTest.php +++ b/tests/Resolver/TemplateManagerTest.php @@ -4,99 +4,234 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use TypePHP\Internal\ErrorMessage; use TypePHP\Resolver\TemplateManager; use TypePHP\Tests\Fixtures\Domain\Animal; use TypePHP\Tests\Fixtures\Domain\Car; use TypePHP\Tests\Fixtures\Domain\Cat; use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Generics\ClassLevelTraitService; use TypePHP\Tests\Fixtures\Generics\Container; +use TypePHP\Tests\Fixtures\Generics\DogRepository; +use TypePHP\Tests\Fixtures\Generics\Producer; describe('TemplateManager Unit Tests', function () { afterEach(function () { TemplateManager::popCallFrame('testFunc'); + TemplateManager::$pendingCloneSource = null; }); - test('infers AST TypeNode correctly from raw PHP values', function () { - expect(TemplateManager::inferTypeFromValue(10)->__toString())->toBe('int') - ->and(TemplateManager::inferTypeFromValue('hello')->__toString())->toBe('string') - ->and(TemplateManager::inferTypeFromValue(12.34)->__toString())->toBe('float') - ->and(TemplateManager::inferTypeFromValue(true)->__toString())->toBe('bool') - ->and(TemplateManager::inferTypeFromValue([1, 2, 3])->__toString())->toBe('list') - ->and(TemplateManager::inferTypeFromValue(['a' => 1])->__toString())->toBe('array') - ->and(TemplateManager::inferTypeFromValue(null)->__toString())->toBe('null') - ->and(TemplateManager::inferTypeFromValue(new Dog())->__toString())->toBe(Dog::class) - ; + describe('Type Inference (inferTypeFromValue)', function () { + test('infers AST TypeNode correctly from raw PHP primitives and lists', function () { + expect((string) TemplateManager::inferTypeFromValue(10))->toBe('int') + ->and((string) TemplateManager::inferTypeFromValue('hello'))->toBe('string') + ->and((string) TemplateManager::inferTypeFromValue(12.34))->toBe('float') + ->and((string) TemplateManager::inferTypeFromValue(true))->toBe('bool') + ->and((string) TemplateManager::inferTypeFromValue([1, 2, 3]))->toBe('list') + ->and((string) TemplateManager::inferTypeFromValue(['a' => 1]))->toBe('array') + ->and((string) TemplateManager::inferTypeFromValue(null))->toBe('null') + ; + }); + + test('infers object class names and includes bound generic types from WeakMap', function () { + $dog = new Dog(); + expect((string) TemplateManager::inferTypeFromValue($dog))->toBe(Dog::class); + + $container = new Container($dog); + TemplateManager::bindInstance($container, Container::class . '<' . Dog::class . '>'); + + $inferredGeneric = TemplateManager::inferTypeFromValue($container); + expect($inferredGeneric)->toBeInstanceOf(GenericTypeNode::class) + ->and((string) $inferredGeneric)->toContain(Container::class) + ->and((string) $inferredGeneric)->toContain(Dog::class) + ; + }); }); - test('manages function call stack frame template bindings', function () { - TemplateManager::pushCallFrame('testFunc'); + describe('Call Stack Frame Management', function () { + test('pushes, inspects, binds, and pops function call stack frames', function () { + TemplateManager::pushCallFrame('testFunc'); - expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeFalse(); + expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeFalse() + ->and(TemplateManager::getBoundType('testFunc', null, 'T'))->toBeNull() + ; - TemplateManager::bindTemplate('testFunc', null, 'T', new IdentifierTypeNode('int')); + TemplateManager::bindTemplate('testFunc', null, 'T', new IdentifierTypeNode('int')); - expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeTrue(); - expect(TemplateManager::getBoundType('testFunc', null, 'T')->__toString())->toBe('int'); + expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeTrue() + ->and((string) TemplateManager::getBoundType('testFunc', null, 'T'))->toBe('int') + ->and(TemplateManager::getBoundTemplates('testFunc', null, []))->toHaveKey('T') + ; - TemplateManager::popCallFrame('testFunc'); - expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeFalse(); + TemplateManager::popCallFrame('testFunc'); + expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeFalse(); + }); + + test('clears and initializes fresh call bindings via clearCallBindings', function () { + TemplateManager::clearCallBindings('testFunc', []); + + expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeFalse(); + + TemplateManager::bindTemplate('testFunc', null, 'T', new IdentifierTypeNode('string')); + expect(TemplateManager::isBound('testFunc', null, 'T'))->toBeTrue(); + }); }); - test('binds object instances via WeakMap', function () { - $container = new Container(new Dog()); - $typeNode = new GenericTypeNode( - new IdentifierTypeNode(Container::class), - [new IdentifierTypeNode(Dog::class)] - ); + describe('Instance WeakMap Bindings', function () { + test('binds object instances via bindInstanceFromNode', function () { + $container = new Container(new Dog()); + $typeNode = new GenericTypeNode( + new IdentifierTypeNode(Container::class), + [new IdentifierTypeNode(Dog::class)] + ); + + $err = TemplateManager::bindInstanceFromNode($container, $typeNode); + + expect($err)->toBeNull() + ->and(TemplateManager::isBound('none', $container, 'T'))->toBeTrue() + ->and((string) TemplateManager::getBoundType('none', $container, 'T'))->toBe(Dog::class) + ->and(TemplateManager::getBoundTemplatesForInstance($container))->toHaveKey('T') + ; + }); + + test('returns ErrorMessage when assigning incompatible invariant generic instance', function () { + $container = new Container(new Dog()); + $dogNode = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Dog::class)]); + TemplateManager::bindInstanceFromNode($container, $dogNode); + + $catNode = new GenericTypeNode(new IdentifierTypeNode(Container::class), [new IdentifierTypeNode(Cat::class)]); + $err = TemplateManager::bindInstanceFromNode($container, $catNode, '$var'); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('expects') + ; + }); + + test('returns null when binding instance to non-matching class name', function () { + $container = new Container(new Dog()); + $typeNode = new GenericTypeNode(new IdentifierTypeNode('NonMatchingClass'), [new IdentifierTypeNode('int')]); - $err = TemplateManager::bindInstanceFromNode($container, $typeNode); + expect(TemplateManager::bindInstanceFromNode($container, $typeNode))->toBeNull(); + }); - expect($err)->toBeNull(); - expect(TemplateManager::isBound('none', $container, 'T'))->toBeTrue(); - expect(TemplateManager::getBoundType('none', $container, 'T')->__toString())->toBe(Dog::class); + test('binds generic types from type string via bindInstance', function () { + $container = new Container(new Dog()); + $bound = TemplateManager::bindInstance($container, Container::class . '<' . Dog::class . '>'); + + expect($bound)->toBe($container) + ->and(TemplateManager::getBoundTemplatesForInstance($container))->toHaveKey('T') + ; + }); + + test('copies instance bindings to cloned object via copyInstanceBindings and pendingCloneSource', function () { + $original = new Container(new Dog()); + TemplateManager::bindInstance($original, Container::class . '<' . Dog::class . '>'); + + $cloned = new Container(new Dog()); + TemplateManager::copyInstanceBindings($original, $cloned); + + expect(TemplateManager::getBoundTemplatesForInstance($cloned))->toHaveKey('T') + ->and((string) TemplateManager::getBoundType('none', $cloned, 'T'))->toBe(Dog::class) + ; + + $anotherClone = new Container(new Dog()); + TemplateManager::$pendingCloneSource = $original; + + expect(TemplateManager::getBoundTemplatesForInstance($anotherClone))->toHaveKey('T'); + }); }); - test('validates covariance rules in checkVariance', function () { - $existingDog = new IdentifierTypeNode(Dog::class); - $expectedAnimal = new IdentifierTypeNode(Animal::class); - - // Dog is an Animal -> Covariant check passes - $isCovariantValid = TemplateManager::checkVariance( - $existingDog, - $expectedAnimal, - GenericTypeNode::VARIANCE_COVARIANT - ); - expect($isCovariantValid)->toBeTrue(); - - // Car is not an Animal -> Covariant check fails - $existingCar = new IdentifierTypeNode(Car::class); - $isCarValid = TemplateManager::checkVariance( - $existingCar, - $expectedAnimal, - GenericTypeNode::VARIANCE_COVARIANT - ); - expect($isCarValid)->toBeFalse(); + describe('Inherited Template Resolution (@extends, @implements, @use)', function () { + test('resolves inherited generic templates from @extends on parent classes', function () { + $dogRepo = new DogRepository(); + + expect(TemplateManager::getBoundTemplatesForInstance($dogRepo))->toHaveKey('T') + ->and((string) TemplateManager::getBoundType('none', $dogRepo, 'T'))->toBe(Dog::class) + ; + }); + + test('resolves inherited generic templates from @use on traits', function () { + $service = new ClassLevelTraitService(); + + expect(TemplateManager::getBoundTemplatesForInstance($service))->toHaveKey('T') + ->and((string) TemplateManager::getBoundType('none', $service, 'T'))->toBe(Dog::class) + ; + }); }); - test('validates contravariance rules in checkVariance', function () { - $existingAnimal = new IdentifierTypeNode(Animal::class); - $expectedDog = new IdentifierTypeNode(Dog::class); - - // Animal is a supertype of Dog -> Contravariant check passes - $isContravariantValid = TemplateManager::checkVariance( - $existingAnimal, - $expectedDog, - GenericTypeNode::VARIANCE_CONTRAVARIANT - ); - expect($isContravariantValid)->toBeTrue(); - - // Cat is a subtype, not a supertype -> Contravariant check fails - $existingCat = new IdentifierTypeNode(Cat::class); - $isCatValid = TemplateManager::checkVariance( - $existingCat, - $expectedDog, - GenericTypeNode::VARIANCE_CONTRAVARIANT - ); - expect($isCatValid)->toBeFalse(); + describe('Variance Engine (checkVariance)', function () { + test('validates exact type matches and bivariant/mixed targets', function () { + $dog = new IdentifierTypeNode(Dog::class); + $mixed = new IdentifierTypeNode('mixed'); + + expect(TemplateManager::checkVariance($dog, $dog, GenericTypeNode::VARIANCE_INVARIANT))->toBeTrue(); + expect(TemplateManager::checkVariance($dog, $mixed, GenericTypeNode::VARIANCE_INVARIANT))->toBeTrue(); + expect(TemplateManager::checkVariance($dog, new IdentifierTypeNode('int'), GenericTypeNode::VARIANCE_BIVARIANT))->toBeTrue(); + }); + + test('validates covariance rules (subtypes allowed, supertypes/unrelated rejected)', function () { + $dog = new IdentifierTypeNode(Dog::class); + $animal = new IdentifierTypeNode(Animal::class); + $car = new IdentifierTypeNode(Car::class); + + expect(TemplateManager::checkVariance($dog, $animal, GenericTypeNode::VARIANCE_COVARIANT))->toBeTrue(); + + expect(TemplateManager::checkVariance($animal, $dog, GenericTypeNode::VARIANCE_COVARIANT))->toBeFalse(); + + expect(TemplateManager::checkVariance($car, $animal, GenericTypeNode::VARIANCE_COVARIANT))->toBeFalse(); + }); + + test('validates contravariance rules (supertypes allowed, subtypes/unrelated rejected)', function () { + $dog = new IdentifierTypeNode(Dog::class); + $animal = new IdentifierTypeNode(Animal::class); + $cat = new IdentifierTypeNode(Cat::class); + + expect(TemplateManager::checkVariance($animal, $dog, GenericTypeNode::VARIANCE_CONTRAVARIANT))->toBeTrue(); + + expect(TemplateManager::checkVariance($dog, $animal, GenericTypeNode::VARIANCE_CONTRAVARIANT))->toBeFalse(); + + expect(TemplateManager::checkVariance($cat, $dog, GenericTypeNode::VARIANCE_CONTRAVARIANT))->toBeFalse(); + }); + + test('validates union variance checks', function () { + $dog = new IdentifierTypeNode(Dog::class); + $cat = new IdentifierTypeNode(Cat::class); + $unionExpected = new UnionTypeNode([$dog, $cat]); + + expect(TemplateManager::checkVariance($dog, $unionExpected, GenericTypeNode::VARIANCE_COVARIANT))->toBeTrue(); + expect(TemplateManager::checkVariance(new IdentifierTypeNode(Car::class), $unionExpected, GenericTypeNode::VARIANCE_COVARIANT))->toBeFalse(); + $unionExisting = new UnionTypeNode([$dog, $cat]); + $animal = new IdentifierTypeNode(Animal::class); + expect(TemplateManager::checkVariance($unionExisting, $animal, GenericTypeNode::VARIANCE_COVARIANT))->toBeTrue(); + }); + + test('validates intersection variance checks', function () { + $countable = new IdentifierTypeNode('Countable'); + $arrayAccess = new IdentifierTypeNode('ArrayAccess'); + $intersection = new IntersectionTypeNode([$countable, $arrayAccess]); + + $countableArrayAccess = new IdentifierTypeNode(ArrayObject::class); + expect(TemplateManager::checkVariance($countableArrayAccess, $intersection, GenericTypeNode::VARIANCE_COVARIANT))->toBeTrue(); + }); + + test('validates nested generic variance (Producer vs Producer)', function () { + $dogProducer = new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Dog::class)], [GenericTypeNode::VARIANCE_COVARIANT]); + $animalProducer = new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Animal::class)], [GenericTypeNode::VARIANCE_COVARIANT]); + + expect(TemplateManager::checkVariance($dogProducer, $animalProducer, GenericTypeNode::VARIANCE_COVARIANT))->toBeTrue(); + + $carProducer = new GenericTypeNode(new IdentifierTypeNode(Producer::class), [new IdentifierTypeNode(Car::class)], [GenericTypeNode::VARIANCE_COVARIANT]); + expect(TemplateManager::checkVariance($carProducer, $animalProducer, GenericTypeNode::VARIANCE_COVARIANT))->toBeFalse(); + }); + + test('extracts declared template variances via getTemplateVariances', function () { + $producer = new Producer(new Dog()); + $variances = TemplateManager::getTemplateVariances($producer); + + expect($variances)->toHaveKey('T') + ->and($variances['T'])->toBe('covariant'); + }); }); }); diff --git a/tests/Resolver/TemplateSubstitutorTest.php b/tests/Resolver/TemplateSubstitutorTest.php index 2736856..423fe76 100644 --- a/tests/Resolver/TemplateSubstitutorTest.php +++ b/tests/Resolver/TemplateSubstitutorTest.php @@ -2,11 +2,22 @@ declare(strict_types=1); +use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use TypePHP\Resolver\TemplateSubstitutor; @@ -76,6 +87,50 @@ ; }); + test('substitutes template placeholders in CallableTypeNode', function () { + $callableNode = new CallableTypeNode( + new IdentifierTypeNode('callable'), + [new CallableTypeParameterNode(new IdentifierTypeNode('T'), false, false, '$item', false)], + new IdentifierTypeNode('T'), + [] + ); + $bound = ['T' => new IdentifierTypeNode('int')]; + + $result = TemplateSubstitutor::substitute($callableNode, $bound); + + expect($result)->toBeInstanceOf(CallableTypeNode::class) + ->and((string) $result)->toBe('callable(int $item): int') + ; + }); + + test('substitutes template placeholders in ConditionalTypeNode & ConditionalTypeForParameterNode', function () { + $conditional = new ConditionalTypeNode( + new IdentifierTypeNode('T'), + new IdentifierTypeNode('Dog'), + new IdentifierTypeNode('T'), + new IdentifierTypeNode('string'), + false + ); + $bound = ['T' => new IdentifierTypeNode('Dog')]; + + $result = TemplateSubstitutor::substitute($conditional, $bound); + expect($result)->toBeInstanceOf(ConditionalTypeNode::class) + ->and((string) $result->subjectType)->toBe('Dog') + ; + + $paramConditional = new ConditionalTypeForParameterNode( + '$flag', + new IdentifierTypeNode('true'), + new IdentifierTypeNode('T'), + new IdentifierTypeNode('string'), + false + ); + $paramResult = TemplateSubstitutor::substitute($paramConditional, $bound); + expect($paramResult)->toBeInstanceOf(ConditionalTypeForParameterNode::class) + ->and((string) $paramResult->if)->toBe('Dog') + ; + }); + test('substitutes array template placeholders (T[] -> int[])', function () { $arrayNode = new ArrayTypeNode(new IdentifierTypeNode('T')); $boundTemplates = ['T' => new IdentifierTypeNode('int')]; @@ -112,7 +167,7 @@ ; }); - test('substitutes template placeholders inside union types (T|string -> int|string)', function () { + test('substitutes template placeholders inside union and intersection types', function () { $unionNode = new UnionTypeNode([ new IdentifierTypeNode('T'), new IdentifierTypeNode('string'), @@ -120,11 +175,41 @@ $boundTemplates = ['T' => new IdentifierTypeNode('int')]; $result = TemplateSubstitutor::substitute($unionNode, $boundTemplates); - expect($result)->toBeInstanceOf(UnionTypeNode::class) ->and($result->types[0]->name)->toBe('int') ->and($result->types[1]->name)->toBe('string') ; + + $intersection = new IntersectionTypeNode([ + new IdentifierTypeNode('T'), + new IdentifierTypeNode('Countable'), + ]); + $resIntersection = TemplateSubstitutor::substitute($intersection, $boundTemplates); + expect($resIntersection)->toBeInstanceOf(IntersectionTypeNode::class) + ->and($resIntersection->types[0]->name)->toBe('int') + ; + }); + + test('substitutes template placeholders in ArrayShapeNode and ObjectShapeNode', function () { + $unsealed = new ArrayShapeUnsealedTypeNode(new IdentifierTypeNode('T'), new IdentifierTypeNode('string')); + $shape = ArrayShapeNode::createUnsealed([ + new ArrayShapeItemNode(new ConstExprStringNode('id', ConstExprStringNode::SINGLE_QUOTED), false, new IdentifierTypeNode('T')), + ], $unsealed); + $bound = ['T' => new IdentifierTypeNode('positive-int')]; + + $resShape = TemplateSubstitutor::substitute($shape, $bound); + expect($resShape)->toBeInstanceOf(ArrayShapeNode::class) + ->and((string) $resShape->items[0]->valueType)->toBe('positive-int') + ->and((string) $resShape->unsealedType?->valueType)->toBe('positive-int') + ; + + $objShape = new ObjectShapeNode([ + new ObjectShapeItemNode(new IdentifierTypeNode('item'), false, new IdentifierTypeNode('T')), + ]); + $resObjShape = TemplateSubstitutor::substitute($objShape, $bound); + expect($resObjShape)->toBeInstanceOf(ObjectShapeNode::class) + ->and((string) $resObjShape->items[0]->valueType)->toBe('positive-int') + ; }); test('leaves non-template types untouched', function () { diff --git a/tests/RuntimeChecker/GeneratorCheckerTest.php b/tests/RuntimeChecker/GeneratorCheckerTest.php index 5cd0047..520ba67 100644 --- a/tests/RuntimeChecker/GeneratorCheckerTest.php +++ b/tests/RuntimeChecker/GeneratorCheckerTest.php @@ -14,40 +14,82 @@ function sampleGeneratorFixture(): Generator yield 'a' => 10; } +/** + * @return Generator + */ +function singleParamGeneratorFixture(): Generator +{ + yield 10; +} + describe('GeneratorChecker Unit Tests', function () { - test('checkYield accepts valid yielded key and value', function () { - $registry = new TypeValidatorRegistry(); + describe('checkYield', function () { + test('accepts valid yielded key and value', function () { + $registry = new TypeValidatorRegistry(); - $result = GeneratorChecker::checkYield('sampleGeneratorFixture', 'a', 10, $registry); + $result = GeneratorChecker::checkYield('sampleGeneratorFixture', 'a', 10, $registry); - expect($result)->toBe(10); - }); + expect($result)->toBe(10); + }); - test('checkYield returns ErrorMessage on invalid yielded value', function () { - $registry = new TypeValidatorRegistry(); + test('returns ErrorMessage on invalid yielded value', function () { + $registry = new TypeValidatorRegistry(); - $result = GeneratorChecker::checkYield('sampleGeneratorFixture', 'a', -50, $registry); + $result = GeneratorChecker::checkYield('sampleGeneratorFixture', 'a', -50, $registry); - expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain('Return iterator value') - ; - }); + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('Return iterator value') + ; + }); + + test('returns ErrorMessage on invalid yielded key', function () { + $registry = new TypeValidatorRegistry(); - test('checkSend accepts valid TSend input value', function () { - $registry = new TypeValidatorRegistry(); + $result = GeneratorChecker::checkYield('sampleGeneratorFixture', 123, 10, $registry); - $result = GeneratorChecker::checkSend('sampleGeneratorFixture', 100, $registry); + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('Return iterator key') + ; + }); - expect($result)->toBe(100); + test('validates single-template Generator', function () { + $registry = new TypeValidatorRegistry(); + + expect(GeneratorChecker::checkYield('singleParamGeneratorFixture', null, 42, $registry))->toBe(42); + expect(GeneratorChecker::checkYield('singleParamGeneratorFixture', null, -5, $registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('returns value directly when function has no return contract', function () { + $registry = new TypeValidatorRegistry(); + + $result = GeneratorChecker::checkYield('nonExistentFunction', 'k', 'v', $registry); + expect($result)->toBe('v'); + }); }); - test('checkSend returns ErrorMessage on invalid TSend input value', function () { - $registry = new TypeValidatorRegistry(); + describe('checkSend (TSend)', function () { + test('accepts valid TSend input value', function () { + $registry = new TypeValidatorRegistry(); + + $result = GeneratorChecker::checkSend('sampleGeneratorFixture', 100, $registry); + + expect($result)->toBe(100); + }); + + test('returns ErrorMessage on invalid TSend input value', function () { + $registry = new TypeValidatorRegistry(); + + $result = GeneratorChecker::checkSend('sampleGeneratorFixture', -500, $registry); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('Generator sent value (TSend)') + ; + }); - $result = GeneratorChecker::checkSend('sampleGeneratorFixture', -500, $registry); + test('returns null immediately when sendValue is null', function () { + $registry = new TypeValidatorRegistry(); - expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain('Generator sent value (TSend)') - ; + expect(GeneratorChecker::checkSend('sampleGeneratorFixture', null, $registry))->toBeNull(); + }); }); }); diff --git a/tests/RuntimeChecker/InlineCheckerTest.php b/tests/RuntimeChecker/InlineCheckerTest.php index c85e1f7..d00f930 100644 --- a/tests/RuntimeChecker/InlineCheckerTest.php +++ b/tests/RuntimeChecker/InlineCheckerTest.php @@ -5,7 +5,13 @@ use TypePHP\Internal\Checker\InlineChecker; use TypePHP\Internal\Config; use TypePHP\Internal\ErrorMessage; +use TypePHP\Resolver\TemplateManager; +use TypePHP\Tests\Fixtures\Domain\Car; +use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Generics\GenericCollection; +use TypePHP\Tests\Fixtures\Generics\HookedCollection; use TypePHP\Tests\Fixtures\Types\ConfiguredProperty; +use TypePHP\TypePHP; use TypePHP\Validator\TypeValidatorRegistry; describe('InlineChecker Unit Tests', function () { @@ -27,41 +33,207 @@ Config::reset(); }); - test('checkVariable validates scalar types when enabled in config', function () { - $registry = new TypeValidatorRegistry(); + describe('checkVariable: Scalar Validations', function () { + test('validates scalar types when enabled in config', function () { + $registry = new TypeValidatorRegistry(); - $valid = InlineChecker::checkVariable(10, 'positive-int', 'age', __FILE__, $registry); - expect($valid)->toBe(10); + $valid = InlineChecker::checkVariable(10, 'positive-int', 'age', __FILE__, $registry); + expect($valid)->toBe(10); - $invalid = InlineChecker::checkVariable(-5, 'positive-int', 'age', __FILE__, $registry); - expect($invalid)->toBeInstanceOf(ErrorMessage::class); + $invalid = InlineChecker::checkVariable(-5, 'positive-int', 'age', __FILE__, $registry); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('Variable $age must be of type positive-int'); + }); + + test('validates non-empty-string and numeric-string', function () { + $registry = new TypeValidatorRegistry(); + + expect(InlineChecker::checkVariable('hello', 'non-empty-string', 'name', __FILE__, $registry))->toBe('hello'); + expect(InlineChecker::checkVariable('', 'non-empty-string', 'name', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); + + expect(InlineChecker::checkVariable('123.45', 'numeric-string', 'num', __FILE__, $registry))->toBe('123.45'); + expect(InlineChecker::checkVariable('not_numeric', 'numeric-string', 'num', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('ignores scalar validation when scalars toggle is false', function () { + try { + Config::set(['inline_vars' => ['scalars' => false]]); + $registry = new TypeValidatorRegistry(); + + $result = InlineChecker::checkVariable(-5, 'positive-int', 'age', __FILE__, $registry); + expect($result)->toBe(-5); + } finally { + Config::reset(); + } + }); }); - test('checkVariable ignores scalar validation when disabled in config', function () { - Config::set(['inline_vars' => ['scalars' => false]]); - $registry = new TypeValidatorRegistry(); + describe('checkVariable: Arrays & Shapes', function () { + test('validates array shapes and lists', function () { + $registry = new TypeValidatorRegistry(); + + expect(InlineChecker::checkVariable([1, 2, 3], 'list', 'scores', __FILE__, $registry))->toBe([1, 2, 3]); + + expect(InlineChecker::checkVariable([1, -5, 3], 'list', 'scores', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); + + expect(InlineChecker::checkVariable(['id' => 1, 'name' => 'Alice'], 'array{id: int, name: string}', 'user', __FILE__, $registry)) + ->toBe(['id' => 1, 'name' => 'Alice']); + + expect(InlineChecker::checkVariable(['id' => 1], 'array{id: int, name: string}', 'user', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('ignores array validation when arrays toggle is false', function () { + try { + Config::set(['inline_vars' => ['arrays' => false]]); + $registry = new TypeValidatorRegistry(); - $result = InlineChecker::checkVariable(-5, 'positive-int', 'age', __FILE__, $registry); - expect($result)->toBe(-5); + $result = InlineChecker::checkVariable(['bad_key' => 1], 'list', 'scores', __FILE__, $registry); + expect($result)->toBe(['bad_key' => 1]); + } finally { + Config::reset(); + } + }); }); - test('checkProperty validates class properties against @var docblock', function () { - $registry = new TypeValidatorRegistry(); - $fixture = new ConfiguredProperty(); + describe('checkVariable: Objects & Generics Pre-Binding', function () { + test('validates object class instances', function () { + $registry = new TypeValidatorRegistry(); + $dog = new Dog(); + + expect(InlineChecker::checkVariable($dog, Dog::class, 'animal', __FILE__, $registry))->toBe($dog); + expect(InlineChecker::checkVariable(new Car(), Dog::class, 'animal', __FILE__, $registry))->toBeInstanceOf(ErrorMessage::class); + }); + + test('pre-binds generic template on object instance via WeakMap', function () { + $registry = new TypeValidatorRegistry(); + $collection = new GenericCollection(); + + $typeString = 'TypePHP\Tests\Fixtures\Generics\GenericCollection'; + $result = InlineChecker::checkVariable($collection, $typeString, 'dogs', __FILE__, $registry); + + expect($result)->toBe($collection) + ->and(TypePHP::getGenericType($collection))->toBe(Dog::class); + }); + + test('ignores object and generic checks when respective toggles are false', function () { + try { + Config::set(['inline_vars' => ['objects' => false, 'generics' => false]]); + $registry = new TypeValidatorRegistry(); + + $car = new Car(); + expect(InlineChecker::checkVariable($car, Dog::class, 'animal', __FILE__, $registry))->toBe($car); + } finally { + Config::reset(); + } + }); + }); + + describe('checkVariable: Callables & Direct Returns', function () { + test('wraps callable variable in lazy proxy', function () { + $registry = new TypeValidatorRegistry(); + $cb = fn(int $id): string => "user_{$id}"; + + $wrapped = InlineChecker::checkVariable($cb, 'callable(positive-int): non-empty-string', 'formatter', __FILE__, $registry); - $valid = InlineChecker::checkProperty([1, 2, 3], $fixture, 'numbers', __FILE__, $registry); - expect($valid)->toBe([1, 2, 3]); + expect($wrapped)->toBeCallable() + ->and($wrapped(10))->toBe('user_10'); - $invalid = InlineChecker::checkProperty(['invalid'], $fixture, 'numbers', __FILE__, $registry); - expect($invalid)->toBeInstanceOf(ErrorMessage::class); + expect(fn() => $wrapped(-5))->toThrow(TypeError::class, 'positive-int'); + }); + + test('formats error message context as Return value when varName is return', function () { + $registry = new TypeValidatorRegistry(); + + $invalid = InlineChecker::checkVariable(-5, 'positive-int', 'return', __FILE__, $registry); + + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('Return value must be of type positive-int'); + }); + + test('returns value immediately when all inline checks are disabled', function () { + try { + Config::set([ + 'inline_vars' => [ + 'generics' => false, + 'callables' => false, + 'scalars' => false, + 'arrays' => false, + 'objects' => false, + ], + ]); + $registry = new TypeValidatorRegistry(); + + $result = InlineChecker::checkVariable(-5, 'positive-int', 'age', __FILE__, $registry); + expect($result)->toBe(-5); + } finally { + Config::reset(); + } + }); }); - test('checkProperty ignores property validation when disabled in config', function () { - Config::set(['inline_vars' => ['properties' => false]]); - $registry = new TypeValidatorRegistry(); - $fixture = new ConfiguredProperty(); + describe('checkProperty: Instance and Static Properties', function () { + test('validates instance class properties against @var docblock', function () { + $registry = new TypeValidatorRegistry(); + $fixture = new ConfiguredProperty(); + + $valid = InlineChecker::checkProperty([1, 2, 3], $fixture, 'numbers', __FILE__, $registry); + expect($valid)->toBe([1, 2, 3]); + + $invalid = InlineChecker::checkProperty(['invalid'], $fixture, 'numbers', __FILE__, $registry); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('numbers[0]'); + }); + + test('validates static class properties against @var docblock', function () { + $registry = new TypeValidatorRegistry(); + + $valid = InlineChecker::checkProperty('New Title', ConfiguredProperty::class, 'staticTitle', __FILE__, $registry); + expect($valid)->toBe('New Title'); + + $invalid = InlineChecker::checkProperty(12345, ConfiguredProperty::class, 'staticTitle', __FILE__, $registry); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain('staticTitle must be of type string'); + }); + + test('substitutes generic template types in class properties', function () { + if (PHP_VERSION_ID < 80400) { + expect(true)->toBeTrue(); + + return; + } + + $registry = new TypeValidatorRegistry(); + $collection = new HookedCollection(); + + TemplateManager::bindTemplate(HookedCollection::class . '::__construct', $collection, 'T', new \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode(Dog::class)); + + $valid = InlineChecker::checkProperty([new Dog()], $collection, 'items', __FILE__, $registry); + expect($valid)->toBeArray(); + + $invalid = InlineChecker::checkProperty([new Car()], $collection, 'items', __FILE__, $registry); + expect($invalid)->toBeInstanceOf(ErrorMessage::class) + ->and($invalid->getMessage())->toContain("items['0']"); + }); + + test('ignores property checks when properties toggle is false', function () { + try { + Config::set(['inline_vars' => ['properties' => false]]); + $registry = new TypeValidatorRegistry(); + $fixture = new ConfiguredProperty(); + + $result = InlineChecker::checkProperty(['invalid'], $fixture, 'numbers', __FILE__, $registry); + expect($result)->toBe(['invalid']); + } finally { + Config::reset(); + } + }); + + test('returns value untouched for un-annotated properties or invalid inputs', function () { + $registry = new TypeValidatorRegistry(); + $fixture = new ConfiguredProperty(); - $result = InlineChecker::checkProperty(['invalid'], $fixture, 'numbers', __FILE__, $registry); - expect($result)->toBe(['invalid']); + expect(InlineChecker::checkProperty(123, null, 'prop', __FILE__, $registry))->toBe(123); + expect(InlineChecker::checkProperty('raw', $fixture, 'nonExistentProperty', __FILE__, $registry))->toBe('raw'); + }); }); }); diff --git a/tests/RuntimeChecker/ParamCheckerTest.php b/tests/RuntimeChecker/ParamCheckerTest.php index d52744c..56f976a 100644 --- a/tests/RuntimeChecker/ParamCheckerTest.php +++ b/tests/RuntimeChecker/ParamCheckerTest.php @@ -3,37 +3,237 @@ declare(strict_types=1); use TypePHP\Internal\Checker\ParamChecker; +use TypePHP\Internal\Config; use TypePHP\Internal\ErrorMessage; +use TypePHP\Tests\Fixtures\Callables\GenericCallableService; +use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Services\ShiftedParamService; use TypePHP\Tests\Fixtures\Services\UserService; +use TypePHP\Tests\Fixtures\Types\ClassStringFactoryContainer; +use TypePHP\Tests\Fixtures\Types\MagicMethodFixture; use TypePHP\Validator\TypeValidatorRegistry; describe('ParamChecker Unit Tests', function () { - test('checkParams accepts valid parameters matching function contract', function () { - $registry = new TypeValidatorRegistry(); - $target = UserService::class . '::find'; + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('Basic Parameter Contracts', function () { + test('accepts valid parameters matching function contract', function () { + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $err = ParamChecker::checkParams($target, ['id' => 10], new UserService(), $registry); + + expect($err)->toBeNull(); + }); + + test('returns ErrorMessage on invalid parameter type', function () { + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $err = ParamChecker::checkParams($target, ['id' => -5], new UserService(), $registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('positive-int'); + }); + + test('handles omitted optional parameters gracefully without error', function () { + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $err = ParamChecker::checkParams($target, [], new UserService(), $registry); - $err = ParamChecker::checkParams($target, ['id' => 10], new UserService(), $registry); + expect($err)->toBeNull(); + }); - expect($err)->toBeNull(); + test('returns null immediately when params checking is disabled in config', function () { + try { + Config::set(['params' => false]); + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $err = ParamChecker::checkParams($target, ['id' => -5], new UserService(), $registry); + + expect($err)->toBeNull(); + } finally { + Config::reset(); + } + }); }); - test('checkParams returns ErrorMessage on invalid parameter type', function () { - $registry = new TypeValidatorRegistry(); - $target = UserService::class . '::find'; + describe('Generic Array Template Pre-Inference (array, list, T[])', function () { + test('pre-infers K and V from generic array argument before validation', function () { + $registry = new TypeValidatorRegistry(); + $service = new GenericCallableService(); + $target = GenericCallableService::class . '::mapArray'; + + $err = ParamChecker::checkParams($target, [ + 'callback' => fn (int $x) => "val_{$x}", + 'array' => ['a' => 10, 'b' => 20], + ], $service, $registry); + + expect($err)->toBeNull(); + }); + + test('returns ErrorMessage when second array item violates pre-inferred template V', function () { + $registry = new TypeValidatorRegistry(); + $service = new GenericCallableService(); + $target = GenericCallableService::class . '::mapArray'; + + $err = ParamChecker::checkParams($target, [ + 'callback' => fn (int $x) => "val_{$x}", + 'array' => ['a' => 10, 'b' => 'not_an_int'], + ], $service, $registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain("['b']"); + }); + + test('pre-infers template T from list parameter', function () { + $registry = new TypeValidatorRegistry(); + $service = new GenericCallableService(); + $target = GenericCallableService::class . '::mapList'; + + $err = ParamChecker::checkParams($target, [ + 'callback' => fn (int $x) => $x * 2, + 'items' => [1, 'bad_int', 3], + ], $service, $registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('[1]'); + }); + }); + + describe('Bare Generic Template Resolution (@template T)', function () { + test('infers template T from first argument and validates subsequent arguments against T', function () { + $registry = new TypeValidatorRegistry(); + $service = new GenericCallableService(); + $target = GenericCallableService::class . '::transform'; + + $err = ParamChecker::checkParams($target, [ + 'transformer' => fn (int $x) => $x * 2, + 'input' => 21, + ], $service, $registry); + + expect($err)->toBeNull(); + }); + + test('enforces class upper bound on template T of Animal', function () { + $registry = new TypeValidatorRegistry(); + $service = new GenericCallableService(); + $target = GenericCallableService::class . '::formatAnimal'; + + $validDog = new Dog(); + $err = ParamChecker::checkParams($target, [ + 'formatter' => fn (Dog $d) => 'dog_tag', + 'animal' => $validDog, + ], $service, $registry); + + expect($err)->toBeNull(); + }); + }); + + describe('class-string Validation', function () { + test('accepts valid class-string implementing bound interface', function () { + $registry = new TypeValidatorRegistry(); + $target = ClassStringFactoryContainer::class . '::makeCountable'; + + $err = ParamChecker::checkParams($target, [ + 'class' => ArrayObject::class, + ], null, $registry); + + expect($err)->toBeNull(); + }); + + test('returns ErrorMessage when class-string does not satisfy bound interface', function () { + $registry = new TypeValidatorRegistry(); + $target = ClassStringFactoryContainer::class . '::makeCountable'; + + $err = ParamChecker::checkParams($target, [ + 'class' => stdClass::class, + ], null, $registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be a class-string of Countable'); + }); + + test('returns ErrorMessage when class-string is not a valid class name', function () { + $registry = new TypeValidatorRegistry(); + $target = ClassStringFactoryContainer::class . '::makeCountable'; + + $err = ParamChecker::checkParams($target, [ + 'class' => 'NonExistentClass12345', + ], null, $registry); + + expect($err)->toBeInstanceOf(ErrorMessage::class) + ->and($err->getMessage())->toContain('must be a valid class-string'); + }); + }); + + describe('Magic Method Interception (__call and __callStatic)', function () { + test('validates parameters on dynamic instance @method calls routed via __call', function () { + $registry = new TypeValidatorRegistry(); + $fixture = new MagicMethodFixture(); + $target = MagicMethodFixture::class . '::__call'; + + $validErr = ParamChecker::checkParams($target, [ + 'name' => 'processId', + 'arguments' => [42, 'Alice'], + ], $fixture, $registry); + expect($validErr)->toBeNull(); + + $invalidErr = ParamChecker::checkParams($target, [ + 'name' => 'processId', + 'arguments' => [-5, 'Alice'], + ], $fixture, $registry); + expect($invalidErr)->toBeInstanceOf(ErrorMessage::class) + ->and($invalidErr->getMessage())->toContain('positive-int'); + }); + + test('validates variadic parameters on dynamic static @method calls routed via __callStatic', function () { + $registry = new TypeValidatorRegistry(); + $target = MagicMethodFixture::class . '::__callStatic'; - $err = ParamChecker::checkParams($target, ['id' => -5], new UserService(), $registry); + $validErr = ParamChecker::checkParams($target, [ + 'name' => 'fetchList', + 'arguments' => [1, 2, 3], + ], MagicMethodFixture::class, $registry); + expect($validErr)->toBeNull(); - expect($err)->toBeInstanceOf(ErrorMessage::class) - ->and($err->getMessage())->toContain('positive-int') - ; + $invalidErr = ParamChecker::checkParams($target, [ + 'name' => 'fetchList', + 'arguments' => [1, 2, 'invalid_int'], + ], MagicMethodFixture::class, $registry); + expect($invalidErr)->toBeInstanceOf(ErrorMessage::class) + ->and($invalidErr->getMessage())->toContain('$items[2]'); + }); }); - test('checkParams handles omitted optional parameters gracefully', function () { - $registry = new TypeValidatorRegistry(); - $target = UserService::class . '::find'; + describe('Parameter Renaming & Position Shift Resolution', function () { + test('validates inherited contracts when subclass renames parameters ($id -> $userId)', function () { + $registry = new TypeValidatorRegistry(); + $service = new ShiftedParamService(); + $target = ShiftedParamService::class . '::registerUser'; - $err = ParamChecker::checkParams($target, [], new UserService(), $registry); + $validErr = ParamChecker::checkParams($target, [ + 'userId' => 10, + 'userName' => 'Alice', + 'userRole' => 'admin', + ], $service, $registry); + expect($validErr)->toBeNull(); - expect($err)->toBeNull(); + $invalidErr = ParamChecker::checkParams($target, [ + 'userId' => -5, + 'userName' => 'Alice', + 'userRole' => 'admin', + ], $service, $registry); + expect($invalidErr)->toBeInstanceOf(ErrorMessage::class) + ->and($invalidErr->getMessage())->toContain('positive-int'); + }); }); -}); +}); \ No newline at end of file diff --git a/tests/RuntimeChecker/ReturnCheckerTest.php b/tests/RuntimeChecker/ReturnCheckerTest.php index c6cb757..dd20726 100644 --- a/tests/RuntimeChecker/ReturnCheckerTest.php +++ b/tests/RuntimeChecker/ReturnCheckerTest.php @@ -3,41 +3,223 @@ declare(strict_types=1); use TypePHP\Internal\Checker\ReturnChecker; +use TypePHP\Internal\Config; use TypePHP\Internal\ErrorMessage; +use TypePHP\Tests\Fixtures\Collections\ConcreteFileCollection; +use TypePHP\Tests\Fixtures\Collections\PluginConfiguration; +use TypePHP\Tests\Fixtures\Conditionals\ConditionalReturnService;; +use TypePHP\Tests\Fixtures\Generics\DogConditionalBox; +use TypePHP\Tests\Fixtures\Services\AdminEntityFactory; use TypePHP\Tests\Fixtures\Services\FluentService; +use TypePHP\Tests\Fixtures\Services\UserEntityFactory; use TypePHP\Tests\Fixtures\Services\UserService; +use TypePHP\Tests\Fixtures\Types\MagicMethodFixture; use TypePHP\Validator\TypeValidatorRegistry; describe('ReturnChecker Unit Tests', function () { - test('checkReturn accepts valid return values matching function contract', function () { - $registry = new TypeValidatorRegistry(); - $target = UserService::class . '::find'; + beforeEach(function () { + Config::reset(); + Config::set(['returns' => true]); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('Basic Return Contracts', function () { + test('accepts valid return values matching function contract', function () { + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $value = ['id' => 10, 'name' => 'Alice']; + $result = ReturnChecker::checkReturn($target, $value, new UserService(), ['id' => 10], $registry, fn () => null); + + expect($result)->toBe($value); + }); + + test('returns ErrorMessage when return shape contract is violated', function () { + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $badValue = ['id' => -5, 'name' => 'Alice']; + $result = ReturnChecker::checkReturn($target, $badValue, new UserService(), ['id' => -5], $registry, fn () => null); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain("Return value['id'] must be of type positive-int"); + }); + + test('returns value directly when returns checking is disabled in config', function () { + try { + Config::set(['returns' => false]); + $registry = new TypeValidatorRegistry(); + $target = UserService::class . '::find'; + + $badValue = ['id' => -99, 'name' => 'Alice']; + $result = ReturnChecker::checkReturn($target, $badValue, new UserService(), ['id' => -99], $registry, fn () => null); - $value = ['id' => 10, 'name' => 'Alice']; - $result = ReturnChecker::checkReturn($target, $value, new UserService(), ['id' => 10], $registry, fn () => null); + expect($result)->toBe($badValue); + } finally { + Config::reset(); + } + }); + }); + + describe('$this Identity Constraints', function () { + test('accepts valid $this instance return', function () { + $registry = new TypeValidatorRegistry(); + $target = FluentService::class . '::setValidSelf'; + $service = new FluentService(); + + $result = ReturnChecker::checkReturn($target, $service, $service, [], $registry, fn () => null); + + expect($result)->toBe($service); + }); + + test('returns ErrorMessage when method returns a new instance instead of $this', function () { + $registry = new TypeValidatorRegistry(); + $target = FluentService::class . '::setInvalidSelf'; + $service = new FluentService(); + + $result = ReturnChecker::checkReturn($target, new FluentService(), $service, [], $registry, fn () => null); - expect($result)->toBe($value); + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('must be $this instance'); + }); }); - test('checkReturn returns ErrorMessage when return shape is violated', function () { - $registry = new TypeValidatorRegistry(); - $target = UserService::class . '::find'; + describe('Late Static Binding (@return static)', function () { + test('accepts returned instance matching late-static calling class', function () { + $registry = new TypeValidatorRegistry(); + $target = UserEntityFactory::class . '::create'; + $instance = new UserEntityFactory(); - $badValue = ['id' => -5, 'name' => 'Alice']; - $result = ReturnChecker::checkReturn($target, $badValue, new UserService(), ['id' => -5], $registry, fn () => null); + $result = ReturnChecker::checkReturn($target, $instance, UserEntityFactory::class, [], $registry, fn () => null); - expect($result)->toBeInstanceOf(ErrorMessage::class); + expect($result)->toBe($instance); + }); + + test('returns ErrorMessage when method returns sibling class instead of late-static calling class', function () { + $registry = new TypeValidatorRegistry(); + $target = UserEntityFactory::class . '::createSibling'; + $siblingInstance = new AdminEntityFactory(); + + $result = ReturnChecker::checkReturn($target, $siblingInstance, UserEntityFactory::class, [], $registry, fn () => null); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain('must be of type TypePHP\Tests\Fixtures\Services\UserEntityFactory'); + }); }); - test('checkReturn enforces $this identity constraints', function () { - $registry = new TypeValidatorRegistry(); - $target = FluentService::class . '::setInvalidSelf'; - $service = new FluentService(); + describe('Parameter-Based Conditional Returns ($param is Target ? A : B)', function () { + test('evaluates matching condition branch (positive-int)', function () { + $registry = new TypeValidatorRegistry(); + $service = new ConditionalReturnService(); + $target = ConditionalReturnService::class . '::formatByParameter'; + + $result = ReturnChecker::checkReturn($target, 42, $service, ['format' => 'int', 'value' => 42], $registry, fn () => null); + expect($result)->toBe(42); + + $badResult = ReturnChecker::checkReturn($target, -10, $service, ['format' => 'int', 'value' => -10], $registry, fn () => null); + expect($badResult)->toBeInstanceOf(ErrorMessage::class) + ->and($badResult->getMessage())->toContain('positive-int'); + }); + + test('evaluates fallback else branch (non-empty-string)', function () { + $registry = new TypeValidatorRegistry(); + $service = new ConditionalReturnService(); + $target = ConditionalReturnService::class . '::formatByParameter'; + + $result = ReturnChecker::checkReturn($target, 'valid_text', $service, ['format' => 'other', 'value' => 'valid_text'], $registry, fn () => null); + expect($result)->toBe('valid_text'); + + $badResult = ReturnChecker::checkReturn($target, '', $service, ['format' => 'other', 'value' => ''], $registry, fn () => null); + expect($badResult)->toBeInstanceOf(ErrorMessage::class) + ->and($badResult->getMessage())->toContain('non-empty-string'); + }); + + test('evaluates negated parameter conditions ($flag is not true)', function () { + $registry = new TypeValidatorRegistry(); + $service = new ConditionalReturnService(); + $target = ConditionalReturnService::class . '::formatByNegation'; + + $result = ReturnChecker::checkReturn($target, 'active', $service, ['flag' => false, 'value' => 'active'], $registry, fn () => null); + expect($result)->toBe('active'); + + $resultInt = ReturnChecker::checkReturn($target, 100, $service, ['flag' => true, 'value' => 100], $registry, fn () => null); + expect($resultInt)->toBe(100); + }); + }); + + describe('Template-Based Conditional Returns (T is Target ? A : B)', function () { + test('evaluates conditional return based on bound generic template T', function () { + $registry = new TypeValidatorRegistry(); + $box = new DogConditionalBox(); + $target = DogConditionalBox::class . '::processInput'; + + $result = ReturnChecker::checkReturn($target, 100, $box, ['input' => 100], $registry, fn () => null); + expect($result)->toBe(100); + + $badResult = ReturnChecker::checkReturn($target, -50, $box, ['input' => -50], $registry, fn () => null); + expect($badResult)->toBeInstanceOf(ErrorMessage::class) + ->and($badResult->getMessage())->toContain('positive-int'); + }); + }); + + describe('Dynamic Magic @method Returns via __call', function () { + test('validates return shape on dynamic @method calls', function () { + $registry = new TypeValidatorRegistry(); + $fixture = new MagicMethodFixture(); + $target = MagicMethodFixture::class . '::__call'; + + $validPayload = ['id' => 10, 'tags' => ['php', 'typephp']]; + $result = ReturnChecker::checkReturn($target, $validPayload, $fixture, [ + 'name' => 'buildPayload', + 'arguments' => [[10], 'active'], + ], $registry, fn () => null); + + expect($result)->toBe($validPayload); + }); + + test('returns ErrorMessage when dynamic @method return violates shape contract', function () { + $registry = new TypeValidatorRegistry(); + $fixture = new MagicMethodFixture(); + $target = MagicMethodFixture::class . '::__call'; + + $badPayload = ['id' => -1, 'tags' => ['php']]; + $result = ReturnChecker::checkReturn($target, $badPayload, $fixture, [ + 'name' => 'buildPayload', + 'arguments' => [[10], 'active'], + ], $registry, fn () => null); + + expect($result)->toBeInstanceOf(ErrorMessage::class) + ->and($result->getMessage())->toContain("Return value['id'] must be of type positive-int"); + }); + }); + + describe('Traversable & Iterable Return Handling', function () { + test('does not wrap concrete collection in IteratorProxy on return', function () { + $registry = new TypeValidatorRegistry(); + $config = new PluginConfiguration(); + $target = PluginConfiguration::class . '::getStyleFilesWithDocblock'; + + $files = new ConcreteFileCollection(); + $wrappedCalled = false; + + $result = ReturnChecker::checkReturn( + $target, + $files, + $config, + [], + $registry, + function () use (&$wrappedCalled) { + $wrappedCalled = true; - $result = ReturnChecker::checkReturn($target, new FluentService(), $service, [], $registry, fn () => null); + return null; + } + ); - expect($result)->toBeInstanceOf(ErrorMessage::class) - ->and($result->getMessage())->toContain('must be $this instance') - ; + expect($result)->toBe($files) + ->and($wrappedCalled)->toBeFalse(); + }); }); -}); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php b/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php index ffd4e25..19fe488 100644 --- a/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php +++ b/tests/TypeChecking/Boundaries/AnonymousClassContractsTest.php @@ -135,7 +135,8 @@ public function find(int $id): array ; expect(fn () => $container->title = '') - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); }); }); diff --git a/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php b/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php index 419df11..96a0bfe 100644 --- a/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php +++ b/tests/TypeChecking/Boundaries/MultiBranchConditionalReturnsTest.php @@ -103,7 +103,8 @@ expect($service->formatByNegation(true, 100))->toBe(100); expect(fn () => $service->formatByNegation(true, -50)) - ->toThrow(TypeError::class, 'Return value must be of type positive-int'); + ->toThrow(TypeError::class, 'Return value must be of type positive-int') + ; }); }); }); diff --git a/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php b/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php index 2591ab6..40f4798 100644 --- a/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php +++ b/tests/TypeChecking/Boundaries/PhpstanAndPsalmTagPriorityTest.php @@ -166,7 +166,8 @@ function handleCovariantProducer(CovariantProducer $producer): mixed $producer = new CovariantProducer(new Dog()); expect(TypePHP::getGenericVariance($producer))->toBe('covariant') - ->and(handleCovariantProducer($producer))->toBeInstanceOf(Dog::class); + ->and(handleCovariantProducer($producer))->toBeInstanceOf(Dog::class) + ; }); }); }); diff --git a/tests/TypeChecking/Boundaries/PropertyHooksTest.php b/tests/TypeChecking/Boundaries/PropertyHooksTest.php index 435c620..064379a 100644 --- a/tests/TypeChecking/Boundaries/PropertyHooksTest.php +++ b/tests/TypeChecking/Boundaries/PropertyHooksTest.php @@ -76,7 +76,7 @@ expect($fixture->unvalidatedHook)->toBe(-50); }); - test('validates asymmetric visibility properties combined with property hooks', function () { + test('validates asymmetric visibility properties combined with property hooks', function () { $profile = new HookedUser(); $profile->updateProfile(100, 'Bob'); diff --git a/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php b/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php index fb78d4f..86bc6c9 100644 --- a/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php +++ b/tests/TypeChecking/Boundaries/ReadonlyPropertiesTest.php @@ -92,7 +92,8 @@ function testObjectShapeOnReadonly(object $obj): bool ; expect(fn () => $container->initialize(10, '')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); }); }); diff --git a/tests/TypeChecking/CallablesAndIterators/ConcreteIterablePropertyAssignmentTest.php b/tests/TypeChecking/CallablesAndIterators/ConcreteIterablePropertyAssignmentTest.php index bf1135b..c96d31e 100644 --- a/tests/TypeChecking/CallablesAndIterators/ConcreteIterablePropertyAssignmentTest.php +++ b/tests/TypeChecking/CallablesAndIterators/ConcreteIterablePropertyAssignmentTest.php @@ -15,7 +15,8 @@ $config->setStyleFiles($files); expect($config->getStyleFiles())->toBeInstanceOf(ConcreteFileCollection::class) - ->and($config->getStyleFiles())->toBe($files); + ->and($config->getStyleFiles())->toBe($files) + ; }); test('does not wrap concrete collection when method has explicit @param ConcreteFileCollection docblock', function () { @@ -26,7 +27,8 @@ $config->setStyleFilesWithDocblock($files); expect($config->styleFiles)->toBeInstanceOf(ConcreteFileCollection::class) - ->and($config->styleFiles)->toBe($files); + ->and($config->styleFiles)->toBe($files) + ; }); test('does not wrap concrete collection when method has explicit @return ConcreteFileCollection docblock', function () { @@ -38,7 +40,8 @@ $returned = $config->getStyleFilesWithDocblock(); expect($returned)->toBeInstanceOf(ConcreteFileCollection::class) - ->and($returned)->toBe($files); + ->and($returned)->toBe($files) + ; }); }); @@ -50,7 +53,8 @@ $config = new PluginConfiguration($files); expect($config->promotedFiles)->toBeInstanceOf(ConcreteFileCollection::class) - ->and($config->promotedFiles)->toBe($files); + ->and($config->promotedFiles)->toBe($files) + ; }); test('handles nullable concrete collection parameter cleanly', function () { @@ -88,4 +92,4 @@ expect($result)->toBe(['valid_a.css', 'valid_b.css']); }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php index e0210e1..e5aae56 100644 --- a/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php +++ b/tests/TypeChecking/CallablesAndIterators/GenericCallablesTest.php @@ -77,4 +77,72 @@ function testGenericComparator(callable $comparator, mixed $a, mixed $b): bool ; }); }); + + describe('Higher-Order Generic Array Transformers (array with callable(V): V2)', function () { + test('infers template parameters from array and validates array items on entry', function () { + $service = new GenericCallableService(); + $stringify = fn (int $n): string => "val_{$n}"; + + expect($service->mapArray($stringify, ['item1' => 10, 'item2' => 20])) + ->toBe(['item1' => 'val_10', 'item2' => 'val_20']) + ; + + expect(fn () => $service->mapArray($stringify, ['item1' => 10, 'item2' => 'invalid_string'])) + ->toThrow(TypeError::class, "['item2']") + ; + }); + + test('validates sequential lists with list and callback(T): R', function () { + $service = new GenericCallableService(); + $double = fn (int $n): int => $n * 2; + + expect($service->mapList($double, [1, 2, 3]))->toBe([2, 4, 6]); + + expect(fn () => $service->mapList($double, [1, 'bad_int', 3])) + ->toThrow(TypeError::class, '[1]') + ; + }); + + test('validates both key and value passed into callback(K, V): V2', function () { + $service = new GenericCallableService(); + $combiner = fn (string $k, int $v): string => "{$k}:{$v}"; + + $result = $service->mapWithKey($combiner, ['alpha' => 10, 'beta' => 20]); + expect($result)->toBe(['alpha' => 'alpha:10', 'beta' => 'beta:20']); + + expect(fn () => $service->mapWithKey($combiner, [0 => 10])) + ->toThrow(TypeError::class, 'key') + ; + }); + + test('throws TypeError when callback parameter type conflicts with inferred array value V', function () { + $service = new GenericCallableService(); + + $stringOnlyCallback = fn (string $s): string => strtoupper($s); + + expect(fn () => $service->mapArray($stringOnlyCallback, ['a' => 10])) + ->toThrow(TypeError::class, 'must be of type string') + ; + }); + + test('accepts PHP 8.1+ first-class callables in generic array transformer', function () { + $service = new GenericCallableService(); + $doubler = new class () { + public function double(int $n): string + { + return "doubled_{$n}"; + } + }; + + $result = $service->mapArray($doubler->double(...), ['a' => 5, 'b' => 10]); + expect($result)->toBe(['a' => 'doubled_5', 'b' => 'doubled_10']); + }); + + test('handles empty array cleanly without crashing on template inference', function () { + $service = new GenericCallableService(); + $fn = fn (int $x): string => (string) $x; + + expect($service->mapArray($fn, []))->toBe([]); + }); + }); }); diff --git a/tests/Visitor/FunctionContractInjectorTest.php b/tests/Visitor/FunctionContractInjectorTest.php index a90a990..77d11c4 100644 --- a/tests/Visitor/FunctionContractInjectorTest.php +++ b/tests/Visitor/FunctionContractInjectorTest.php @@ -2,74 +2,236 @@ declare(strict_types=1); +use PhpParser\Comment\Doc; use PhpParser\Node; +use TypePHP\Internal\Config; use TypePHP\Internal\Visitor\FunctionContractInjector; describe('FunctionContractInjector Unit Tests', function () { - test('injects setupScope and return check into function with docblocks', function () { - $doc = new PhpParser\Comment\Doc('/** @param positive-int $id @return non-empty-string */'); - - $fn = new Node\Stmt\Function_('testUser', [ - 'params' => [ - new Node\Param(new Node\Expr\Variable('id'), null, new Node\Identifier('int')), - ], - 'stmts' => [ - new Node\Stmt\Return_(new Node\Scalar\String_('alice')), - ], - ], [ - 'comments' => [$doc], - ]); - - FunctionContractInjector::inject($fn); - - expect($fn->stmts)->not()->toBeEmpty(); - - $firstStmt = $fn->stmts[0]; - expect($firstStmt)->toBeInstanceOf(Node\Stmt\If_::class) - ->and($firstStmt->getAttribute('typephp_injected'))->toBeTrue() - ; + beforeEach(function () { + Config::reset(); }); - test('injects wrapCallable and wrapIterable for parameters with callable or iterable docblocks', function () { - $doc = new PhpParser\Comment\Doc('/** @param callable(int): string $cb @param iterable $items */'); - - $fn = new Node\Stmt\Function_('processData', [ - 'params' => [ - new Node\Param(new Node\Expr\Variable('cb')), - new Node\Param(new Node\Expr\Variable('items')), - ], - 'stmts' => [], - ], [ - 'comments' => [$doc], - ]); - - FunctionContractInjector::inject($fn); - - // Statement 0 is setupScope, Statement 1 is wrapCallable, Statement 2 is wrapIterable - expect(\count($fn->stmts))->toBeGreaterThanOrEqual(3) - ->and($fn->stmts[1]->getAttribute('typephp_injected'))->toBeTrue() - ->and($fn->stmts[2]->getAttribute('typephp_injected'))->toBeTrue() - ; + afterEach(function () { + Config::reset(); }); - test('does not inject return checks into magic lifecycle methods like constructors', function () { - $fn = new Node\Stmt\ClassMethod('__construct', [ - 'params' => [ - new Node\Param(new Node\Expr\Variable('id')), - ], - 'stmts' => [], - ]); - - FunctionContractInjector::inject($fn); - - // Should have param check (setupScope, wrapCallable, wrapIterable) but NO return check - $hasReturn = false; - foreach ($fn->stmts as $stmt) { - if ($stmt instanceof Node\Stmt\Return_) { - $hasReturn = true; + describe('Parameter Injections', function () { + test('injects setupScope and return check into function with docblocks', function () { + $doc = new Doc('/** @param positive-int $id @return non-empty-string */'); + + $fn = new Node\Stmt\Function_('testUser', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('id'), null, new Node\Identifier('int')), + ], + 'stmts' => [ + new Node\Stmt\Return_(new Node\Scalar\String_('alice')), + ], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + expect($fn->stmts)->not()->toBeEmpty(); + + $firstStmt = $fn->stmts[0]; + expect($firstStmt)->toBeInstanceOf(Node\Stmt\If_::class) + ->and($firstStmt->getAttribute('typephp_injected'))->toBeTrue() + ; + }); + + test('injects wrapCallable and wrapIterable for parameters matching keywords', function () { + $doc = new Doc('/** @param callable(int): string $cb @param iterable $items */'); + + $fn = new Node\Stmt\Function_('processData', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('cb')), + new Node\Param(new Node\Expr\Variable('items')), + ], + 'stmts' => [], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + expect(\count($fn->stmts))->toBeGreaterThanOrEqual(3) + ->and($fn->stmts[1]->getAttribute('typephp_injected'))->toBeTrue() + ->and($fn->stmts[2]->getAttribute('typephp_injected'))->toBeTrue() + ; + }); + + test('does not inject wrapIterable when docblock does not contain iterable keywords', function () { + $doc = new Doc('/** @param positive-int $id */'); + + $fn = new Node\Stmt\Function_('simpleFunc', [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('id')), + ], + 'stmts' => [], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + expect(\count($fn->stmts))->toBe(1); + }); + }); + + describe('Non-Generator Return Wrapping', function () { + test('wraps standard return expressions in ternary checkReturn', function () { + $doc = new Doc('/** @return non-empty-string */'); + + $fn = new Node\Stmt\Function_('getName', [ + 'stmts' => [ + new Node\Stmt\Return_(new Node\Scalar\String_('Alice')), + ], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + $returnStmt = $fn->stmts[0]; + expect($returnStmt)->toBeInstanceOf(Node\Stmt\Return_::class) + ->and($returnStmt->expr)->toBeInstanceOf(Node\Expr\Ternary::class) + ; + }); + + test('wraps native void return statements in if check with null return', function () { + $doc = new Doc('/** @return void */'); + + $fn = new Node\Stmt\Function_('processVoid', [ + 'returnType' => new Node\Identifier('void'), + 'stmts' => [ + new Node\Stmt\Return_(null), + ], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + expect($fn->stmts[0])->toBeInstanceOf(Node\Stmt\If_::class) + ->and($fn->stmts[1])->toBeInstanceOf(Node\Stmt\Return_::class) + ; + }); + + test('appends implicit trailing return check when function has no return statement', function () { + $doc = new Doc('/** @return non-empty-string */'); + + $fn = new Node\Stmt\Function_('noReturnFunc', [ + 'stmts' => [ + new Node\Stmt\Expression(new Node\Expr\Variable('x')), + ], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + $lastStmt = end($fn->stmts); + expect($lastStmt)->toBeInstanceOf(Node\Stmt\Return_::class) + ->and($lastStmt->getAttribute('typephp_injected'))->toBeTrue() + ; + }); + }); + + describe('Generator Return and Yield Wrapping', function () { + test('wraps yield expressions with checkYield and checkSend', function () { + $doc = new Doc('/** @return Generator */'); + + $fn = new Node\Stmt\Function_('genFunc', [ + 'stmts' => [ + new Node\Stmt\Expression( + new Node\Expr\Yield_(new Node\Scalar\LNumber(10), new Node\Scalar\String_('a')) + ), + ], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + $yieldExpr = $fn->stmts[0]->expr; + expect($yieldExpr)->toBeInstanceOf(Node\Expr\Ternary::class); + }); + + test('wraps yield from expressions with wrapIterable', function () { + $doc = new Doc('/** @return Generator */'); + + $fn = new Node\Stmt\Function_('yieldFromFunc', [ + 'stmts' => [ + new Node\Stmt\Expression( + new Node\Expr\YieldFrom(new Node\Expr\Array_()) + ), + ], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($fn); + + $yieldFrom = $fn->stmts[0]->expr; + expect($yieldFrom)->toBeInstanceOf(Node\Expr\YieldFrom::class) + ->and($yieldFrom->expr)->toBeInstanceOf(Node\Expr\FuncCall::class) + ; + }); + }); + + describe('Static vs Instance Methods & Lifecycles', function () { + test('resolves thisArg to static::class for static methods', function () { + $method = new Node\Stmt\ClassMethod('staticMethod', [ + 'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC | Node\Stmt\Class_::MODIFIER_STATIC, + 'stmts' => [], + ]); + + FunctionContractInjector::inject($method); + + $setupIf = $method->stmts[0]; + expect($setupIf)->toBeInstanceOf(Node\Stmt\If_::class); + }); + + test('does not inject return checks into magic lifecycle methods like __construct, __destruct, __clone', function () { + $lifecycleMethods = ['__construct', '__destruct', '__clone']; + + foreach ($lifecycleMethods as $name) { + $method = new Node\Stmt\ClassMethod($name, [ + 'params' => [ + new Node\Param(new Node\Expr\Variable('id')), + ], + 'stmts' => [], + ]); + + FunctionContractInjector::inject($method); + + $hasReturn = false; + foreach ($method->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Return_) { + $hasReturn = true; + } + } + + expect($hasReturn)->toBeFalse(); } - } + }); + }); + + describe('Ignore Tag Suppression (@typephp-ignore)', function () { + test('skips injecting checks when method docblock contains @typephp-ignore', function () { + $doc = new Doc("/**\n * @typephp-ignore\n * @param positive-int \$id\n */"); + + $method = new Node\Stmt\ClassMethod('ignoredMethod', [ + 'stmts' => [], + ], [ + 'comments' => [$doc], + ]); + + FunctionContractInjector::inject($method); - expect($hasReturn)->toBeFalse(); + expect($method->stmts)->toBeEmpty(); + }); }); }); diff --git a/tests/Visitor/PropertyHookInjectorTest.php b/tests/Visitor/PropertyHookInjectorTest.php index ee17d07..71aaccc 100644 --- a/tests/Visitor/PropertyHookInjectorTest.php +++ b/tests/Visitor/PropertyHookInjectorTest.php @@ -6,73 +6,176 @@ return; } +use PhpParser\Comment\Doc; use PhpParser\Node; +use TypePHP\Internal\Config; use TypePHP\Internal\Visitor\PropertyHookInjector; describe('PropertyHookInjector Unit Tests', function () { - test('wraps short get property hooks (get => $expr) in ternary', function () { - $hook = new Node\PropertyHook( - name: 'get', - body: new Node\Scalar\String_('invalid') - ); + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('Get Property Hooks', function () { + test('wraps short get property hooks (get => $expr) in ternary', function () { + $hook = new Node\PropertyHook( + name: 'get', + body: new Node\Scalar\String_('invalid') + ); + + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('title')], + hooks: [$hook] + ); + + PropertyHookInjector::process($prop); - $prop = new Node\Stmt\Property( - flags: Node\Stmt\Class_::MODIFIER_PUBLIC, - props: [new Node\PropertyItem('title')], - hooks: [$hook] - ); + expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Expr\Ternary::class); + }); - PropertyHookInjector::process($prop); + test('wraps return statements inside block get property hooks (get { return $expr; })', function () { + $hook = new Node\PropertyHook( + name: 'get', + body: [ + new Node\Stmt\Return_(new Node\Scalar\String_('hello')), + ] + ); - expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Expr\Ternary::class); + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('title')], + hooks: [$hook] + ); + + PropertyHookInjector::process($prop); + + $body = $prop->hooks[0]->body; + expect($body)->toBeArray() + ->and($body[0])->toBeInstanceOf(Node\Stmt\Return_::class) + ->and($body[0]->expr)->toBeInstanceOf(Node\Expr\Ternary::class) + ; + }); }); - test('wraps short set property hooks (set => $expr) in ternary to avoid parser bugs', function () { - $hook = new Node\PropertyHook( - name: 'set', - body: new Node\Expr\Assign( + describe('Set Property Hooks', function () { + test('wraps short set property hooks (set => $expr) in ternary keeping assignment on false branch', function () { + $assignment = new Node\Expr\Assign( new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), 'title'), new Node\Expr\Variable('value') - ) - ); + ); + + $hook = new Node\PropertyHook( + name: 'set', + body: $assignment + ); + + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('title')], + hooks: [$hook] + ); + + PropertyHookInjector::process($prop); + + expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Expr\Ternary::class) + ->and($prop->hooks[0]->body->else)->toBe($assignment) + ; + }); - $prop = new Node\Stmt\Property( - flags: Node\Stmt\Class_::MODIFIER_PUBLIC, - props: [new Node\PropertyItem('title')], - hooks: [$hook] - ); + test('injects paramCheckStmt at top of block set property hooks', function () { + $hook = new Node\PropertyHook( + name: 'set', + body: [ + new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), 'title'), + new Node\Expr\Variable('value') + ) + ), + ] + ); - PropertyHookInjector::process($prop); + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('title')], + hooks: [$hook] + ); - // The body should remain an expression (Ternary), not an array of statements - expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Expr\Ternary::class); + PropertyHookInjector::process($prop); + + $body = $prop->hooks[0]->body; + expect($body)->toBeArray() + ->and($body[0])->toBeInstanceOf(Node\Stmt\Expression::class) + ->and($body[0]->getAttribute('typephp_injected'))->toBeTrue() + ; + }); + + test('extracts custom parameter name in set property hook (set(int $customVal))', function () { + $hook = new Node\PropertyHook( + name: 'set', + body: [ + new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), 'score'), + new Node\Expr\Variable('customVal') + ) + ), + ] + ); + $hook->params = [ + new Node\Param(new Node\Expr\Variable('customVal'), null, new Node\Identifier('int')), + ]; + + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('score')], + hooks: [$hook] + ); + + PropertyHookInjector::process($prop); + + $body = $prop->hooks[0]->body; + expect($body)->toBeArray() + ->and($body[0]->expr->var->name)->toBe('customVal') + ; + }); }); - test('injects paramCheckStmt into block set property hooks', function () { - $hook = new Node\PropertyHook( - name: 'set', - body: [ - new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), 'title'), - new Node\Expr\Variable('value') - ) - ), - ] - ); - - $prop = new Node\Stmt\Property( - flags: Node\Stmt\Class_::MODIFIER_PUBLIC, - props: [new Node\PropertyItem('title')], - hooks: [$hook] - ); - - PropertyHookInjector::process($prop); - - $body = $prop->hooks[0]->body; - expect($body)->toBeArray() - ->and($body[0])->toBeInstanceOf(Node\Stmt\Expression::class) - ->and($body[0]->getAttribute('typephp_injected'))->toBeTrue() - ; + describe('Ignore Tag Suppression (@typephp-ignore)', function () { + test('skips injecting checks when property docblock contains @typephp-ignore', function () { + $doc = new Doc("/**\n * @typephp-ignore\n * @var positive-int\n */"); + + $hook = new Node\PropertyHook( + name: 'get', + body: new Node\Scalar\String_('unmodified') + ); + + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('unvalidatedProp')], + hooks: [$hook], + attributes: ['comments' => [$doc]] + ); + + PropertyHookInjector::process($prop); + + expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Scalar\String_::class); + }); + + test('skips properties without hooks gracefully', function () { + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('normalProp')] + ); + + PropertyHookInjector::process($prop); + + expect($prop->props[0]->name->toString())->toBe('normalProp'); + }); }); });