Skip to content

Commit 9108910

Browse files
committed
Refactor CallableWrapper and IterableWrapper to use TypePHPTypeError for exception handling; add AdvancedEdgeCasesTest for comprehensive type validation scenarios
1 parent 53ed687 commit 9108910

3 files changed

Lines changed: 154 additions & 19 deletions

File tree

src/Wrapper/CallableWrapper.php

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
namespace TypePHP\Wrapper;
66

7+
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
78
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
9+
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
810
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
911
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
1012
use TypePHP\Contract\ContractParser;
13+
use TypePHP\Exception\TypeError as TypePHPTypeError;
1114
use TypePHP\Internal\ErrorFactory;
1215
use TypePHP\Internal\TypeFormatter;
1316
use TypePHP\Validator\TypeValidatorRegistry;
@@ -22,10 +25,6 @@ final class CallableWrapper
2225
*/
2326
public static function wrap(string $function, string $paramName, mixed $callable, TypeValidatorRegistry $registry): mixed
2427
{
25-
if (! \is_callable($callable)) {
26-
return $callable;
27-
}
28-
2928
$contract = ContractParser::parse($function);
3029
$typeNode = ($paramName === 'return') ? ($contract['return'] ?? null) : ($contract['types'][$paramName] ?? null);
3130
$aliases = $contract['aliases'] ?? [];
@@ -36,16 +35,41 @@ public static function wrap(string $function, string $paramName, mixed $callable
3635

3736
$prefix = ($paramName === 'return') ? "$function(): Return value" : "$function(): Callback \$$paramName";
3837

39-
return self::wrapTypeNode($typeNode, $callable, $prefix, $registry);
38+
// 1. Single Callable
39+
if (\is_callable($callable)) {
40+
return self::wrapTypeNode($typeNode, $callable, $prefix, $registry);
41+
}
42+
43+
// 2. Collections of Callables (e.g. list<callable(...)> or callable[])
44+
if (\is_array($callable) && $typeNode !== null) {
45+
$innerCallableTypeNode = null;
46+
47+
if ($typeNode instanceof GenericTypeNode && \in_array(strtolower($typeNode->type->name), ['list', 'array', 'iterable'], true)) {
48+
$innerCallableTypeNode = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null;
49+
} elseif ($typeNode instanceof ArrayTypeNode) {
50+
$innerCallableTypeNode = $typeNode->type;
51+
}
52+
53+
if ($innerCallableTypeNode instanceof CallableTypeNode) {
54+
$wrappedArray = [];
55+
foreach ($callable as $k => $item) {
56+
if (\is_callable($item)) {
57+
$itemPrefix = $prefix . (\is_int($k) ? "[$k]" : "['$k']");
58+
$wrappedArray[$k] = self::wrapTypeNode($innerCallableTypeNode, $item, $itemPrefix, $registry);
59+
} else {
60+
$wrappedArray[$k] = $item;
61+
}
62+
}
63+
64+
return $wrappedArray;
65+
}
66+
}
67+
68+
return $callable;
4069
}
4170

4271
/**
4372
* Wraps a callable with runtime argument and return value type validation based on a CallableTypeNode AST.
44-
*
45-
* Performs the following steps:
46-
* 1. Validates Closure type restrictions (Closure vs static-closure).
47-
* 2. Returns an interceptor closure that validates arguments before invocation.
48-
* 3. Validates return value after invocation and recursively wraps returned callbacks.
4973
*/
5074
public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string $prefix, TypeValidatorRegistry $registry): mixed
5175
{
@@ -63,7 +87,7 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string
6387

6488
$err = $registry->validate($result, $typeNode->returnType, "$prefix return value");
6589
if ($err !== null) {
66-
throw ErrorFactory::prepareException(new \TypeError($err->getMessage()));
90+
throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage()));
6791
}
6892

6993
if ($typeNode->returnType instanceof CallableTypeNode && \is_callable($result)) {
@@ -80,13 +104,13 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string
80104
private static function enforceClosureConstraints(string $identifierName, mixed $callable, string $prefix): void
81105
{
82106
if (str_contains($identifierName, 'closure') && ! ($callable instanceof \Closure)) {
83-
throw ErrorFactory::prepareException(new \TypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given'));
107+
throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given'));
84108
}
85109

86110
if (str_contains($identifierName, 'static') && $callable instanceof \Closure) {
87111
$refFunc = new \ReflectionFunction($callable);
88112
if ($refFunc->getClosureThis() !== null) {
89-
throw ErrorFactory::prepareException(new \TypeError($prefix . ' must be a static Closure (not bound to $this)'));
113+
throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be a static Closure (not bound to $this)'));
90114
}
91115
}
92116
}
@@ -105,7 +129,7 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar
105129
for ($vIdx = $index; $vIdx < $argCount; $vIdx++) {
106130
$err = $registry->validate($args[$vIdx], $paramNode->type, "$prefix variadic argument #" . ($vIdx + 1));
107131
if ($err !== null) {
108-
throw ErrorFactory::prepareException(new \TypeError($err->getMessage()));
132+
throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage()));
109133
}
110134
}
111135

@@ -115,9 +139,9 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar
115139
if (\array_key_exists($index, $args)) {
116140
$err = $registry->validate($args[$index], $paramNode->type, "$prefix argument #" . ($index + 1));
117141
if ($err !== null) {
118-
throw ErrorFactory::prepareException(new \TypeError($err->getMessage()));
142+
throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage()));
119143
}
120144
}
121145
}
122146
}
123-
}
147+
}

src/Wrapper/IterableWrapper.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
1010
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
1111
use TypePHP\Contract\ContractParser;
12+
use TypePHP\Exception\TypeError as TypePHPTypeError;
1213
use TypePHP\Internal\ErrorFactory;
1314
use TypePHP\Validator\TypeValidatorRegistry;
1415

@@ -108,14 +109,14 @@ private static function createValidationCallback(
108109
if ($keyTypeNode !== null && $key !== null) {
109110
$err = $registry->validate($key, $keyTypeNode, "$prefix key");
110111
if ($err !== null) {
111-
throw ErrorFactory::prepareException(new \TypeError($err->getMessage()));
112+
throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage()));
112113
}
113114
}
114115

115116
if ($itemTypeNode !== null) {
116117
$err = $registry->validate($value, $itemTypeNode, "$prefix value");
117118
if ($err !== null) {
118-
throw ErrorFactory::prepareException(new \TypeError($err->getMessage()));
119+
throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage()));
119120
}
120121
}
121122
};
@@ -136,4 +137,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal
136137
yield $key => $value;
137138
}
138139
}
139-
}
140+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use TypePHP\Exception\TypeError;
6+
use TypePHP\Tests\Fixtures\Generics\GenericCollection;
7+
8+
/**
9+
* 1. Function accepting a list of lazy callables
10+
*
11+
* @param list<callable(positive-int): non-empty-string> $formatters
12+
*/
13+
function processFormatterList(array $formatters, int $id): array
14+
{
15+
$results = [];
16+
foreach ($formatters as $formatter) {
17+
$results[] = $formatter($id);
18+
}
19+
20+
return $results;
21+
}
22+
23+
describe('Advanced Edge-Case Behaviors', function () {
24+
describe('Skipped and Deeply Nested Array Destructuring with @var', function () {
25+
test('validates variables when skipping elements with empty commas in destructuring', function () {
26+
/**
27+
* @var positive-int $id
28+
* @var non-empty-string $username
29+
*/
30+
[$id, , $username] = [10, 'skipped_token', 'Alice'];
31+
32+
expect($id)->toBe(10)
33+
->and($username)->toBe('Alice');
34+
35+
expect(function () {
36+
/**
37+
* @var positive-int $id
38+
* @var non-empty-string $username
39+
*/
40+
[$id, , $username] = [-5, 'skipped_token', 'Alice'];
41+
})->toThrow(TypeError::class, 'Variable $id must be of type positive-int');
42+
});
43+
44+
test('validates variables in deeply nested array destructuring', function () {
45+
/**
46+
* @var positive-int $id
47+
* @var non-empty-string $street
48+
* @var int<10000, 99999> $zip
49+
*/
50+
[$id, [$street, $zip]] = [42, ['Broadway', 90210]];
51+
52+
expect($id)->toBe(42)
53+
->and($street)->toBe('Broadway')
54+
->and($zip)->toBe(90210);
55+
56+
expect(function () {
57+
/**
58+
* @var positive-int $id
59+
* @var non-empty-string $street
60+
* @var int<10000, 99999> $zip
61+
*/
62+
[$id, [$street, $zip]] = [42, ['', 90210]];
63+
})->toThrow(TypeError::class, 'Variable $street must be of type non-empty-string');
64+
});
65+
});
66+
67+
describe('Nullable Generic Elements in Collections', function () {
68+
test('accepts null and valid refined scalars in Collection<?positive-int>', function () {
69+
/** @var GenericCollection<?positive-int> $collection */
70+
$collection = new GenericCollection();
71+
72+
$collection->add(10);
73+
$collection->add(null);
74+
$collection->add(20);
75+
76+
expect($collection->count())->toBe(3)
77+
->and($collection->toArray())->toBe([10, null, 20]);
78+
});
79+
80+
test('throws TypeError when adding invalid scalar to Collection<?positive-int>', function () {
81+
/** @var GenericCollection<?positive-int> $collection */
82+
$collection = new GenericCollection();
83+
84+
expect(fn () => $collection->add(-50))
85+
->toThrow(TypeError::class, 'Argument $item (template T = ?positive-int) must be of type positive-int, negative int (-50) given');
86+
});
87+
});
88+
89+
describe('Collections of Lazy Callables', function () {
90+
test('executes and validates a list of lazy callable proxies', function () {
91+
$formatters = [
92+
fn (int $id): string => "id_{$id}",
93+
fn (int $id): string => "user#{$id}",
94+
];
95+
96+
$results = processFormatterList($formatters, 42);
97+
expect($results)->toBe(['id_42', 'user#42']);
98+
});
99+
100+
test('throws TypeError when a callable in the collection returns an invalid type', function () {
101+
$formatters = [
102+
fn (int $id): string => "id_{$id}",
103+
fn (int $id): string => '',
104+
];
105+
106+
expect(fn () => processFormatterList($formatters, 42))
107+
->toThrow(TypeError::class, 'Callback $formatters[1] return value must be of type non-empty-string');
108+
});
109+
});
110+
});

0 commit comments

Comments
 (0)