Skip to content

Commit 45a5984

Browse files
committed
Match typed arguments by type regardless of position
resolve() now binds typed parameters in a first pass: a positional object is claimed by the parameter declaring its type wherever it appears, so a leading untyped (scalar) parameter can no longer consume an object meant for a typed parameter declared after it. Untyped parameters then take the remaining positional arguments in declaration order. Add tests for out-of-order typed matching and update the README resolution order and a type-first example accordingly.
1 parent b61bdd6 commit 45a5984

3 files changed

Lines changed: 175 additions & 35 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ composer require respect/parameter
1515
For each parameter the resolver tries, in order:
1616

1717
1. An explicit **named** argument (keyed by parameter name)
18-
2. A **positional** argument already matching the parameter **type**
18+
2. An argument matching the parameter **type**
1919
3. The **container**, matched by **type** (non-builtin)
20-
4. The next **positional** argument
20+
4. The next remaining **positional** argument
2121
5. The parameter's **default value**
2222
6. `null`
2323

24-
A trailing **variadic** parameter receives a matching named argument (if any) followed by every remaining positional argument.
24+
Typed parameters are bound first (steps 1–3), so a positional object is matched by type wherever it sits and an earlier untyped parameter can't consume it; untyped parameters then take the leftover positional arguments in declaration order (step 4). A trailing **variadic** parameter receives a matching named argument (if any) followed by every remaining positional argument.
2525

2626
```php
2727
use Respect\Parameter\ContainerResolver;
@@ -43,6 +43,20 @@ notify(...$args);
4343
$reflection->newInstanceArgs($args);
4444
```
4545

46+
### Type-first matching
47+
48+
A positional object is bound to the parameter that declares its type, wherever each sits in the list —
49+
so an untyped parameter never accidentally swallows it:
50+
51+
```php
52+
function notify(string $subject, Mailer $mailer) {
53+
// ...
54+
}
55+
56+
$args = $resolver->resolve(new ReflectionFunction('notify'), [$mailer, 'Hello']);
57+
// ['Hello', Mailer] — $mailer matched by type, 'Hello' fell through to $subject
58+
```
59+
4660
### Named arguments
4761

4862
`resolve()` accepts named arguments too — keyed by parameter name, taking precedence over the

src/ContainerResolver.php

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
use ReflectionParameter;
2020

2121
use function array_key_exists;
22+
use function array_key_last;
23+
use function array_keys;
24+
use function array_pop;
2225
use function array_values;
2326
use function assert;
2427
use function count;
@@ -46,10 +49,11 @@ public function __construct(private ContainerInterface $container)
4649
* Resolve the arguments for a function/constructor.
4750
*
4851
* Provided arguments may be positional (int-keyed) or named (string-keyed by parameter name).
49-
* For each parameter, in order: an explicit named argument wins; then a positional argument
50-
* already matching the parameter type; then the container by type; then the next positional
51-
* argument; then the parameter default; otherwise null. A trailing variadic parameter receives
52-
* a matching named argument (if any) followed by every remaining positional argument.
52+
* For each parameter, in order: an explicit named argument wins; then any not-yet-consumed
53+
* positional argument matching the parameter type (regardless of its position); then the
54+
* container by type; then the next not-yet-consumed positional argument; then the parameter
55+
* default; otherwise null. A trailing variadic parameter receives a matching named argument
56+
* (if any) followed by every remaining positional argument.
5357
*
5458
* @param array<int|string, mixed> $arguments
5559
*
@@ -72,51 +76,90 @@ public function resolve(ReflectionFunctionAbstract $reflection, array $arguments
7276
}
7377
}
7478

75-
$resolved = [];
76-
$index = 0;
77-
$count = count($positional);
78-
79-
foreach ($parameters as $param) {
80-
$name = $param->getName();
81-
82-
if ($param->isVariadic()) {
83-
if (array_key_exists($name, $named)) {
84-
$resolved[] = $named[$name];
85-
}
86-
87-
while ($index < $count) {
88-
$resolved[] = $positional[$index++];
89-
}
79+
// A variadic parameter is always the trailing one in PHP, so pull it off once here
80+
// instead of testing isVariadic() on every parameter inside the passes below.
81+
$variadic = null;
82+
$lastKey = array_key_last($parameters);
83+
if ($parameters[$lastKey]->isVariadic()) {
84+
$variadic = $parameters[$lastKey];
85+
array_pop($parameters);
86+
}
9087

91-
break;
92-
}
88+
$slot = [];
89+
$used = [];
90+
$deferred = [];
9391

92+
// First pass: bind named arguments and typed parameters. A typed parameter claims any
93+
// matching positional argument regardless of position, so a leading scalar parameter can
94+
// never steal an object meant for a typed parameter declared after it.
95+
foreach ($parameters as $index => $param) {
96+
$name = $param->getName();
9497
if (array_key_exists($name, $named)) {
95-
$resolved[] = $named[$name];
98+
$slot[$index] = $named[$name];
9699

97100
continue;
98101
}
99102

100103
$type = self::typeName($param);
104+
if ($type !== null) {
105+
$match = self::firstUnused($positional, $used, $type);
106+
if ($match !== null) {
107+
$slot[$index] = $positional[$match];
108+
$used[$match] = true;
101109

102-
if ($type !== null && isset($positional[$index]) && $positional[$index] instanceof $type) {
103-
$resolved[] = $positional[$index++];
110+
continue;
111+
}
104112

105-
continue;
113+
if ($this->container->has($type)) {
114+
$slot[$index] = $this->container->get($type);
115+
116+
continue;
117+
}
106118
}
107119

108-
if ($type !== null && $this->container->has($type)) {
109-
$resolved[] = $this->container->get($type);
120+
$deferred[$index] = $param;
121+
}
110122

111-
continue;
123+
// Second pass: fill the remaining parameters from leftover positional arguments in order,
124+
// advancing a single cursor instead of rescanning from the start for each one.
125+
$cursor = 0;
126+
$total = count($positional);
127+
foreach ($deferred as $index => $param) {
128+
while ($cursor < $total && ($used[$cursor] ?? false)) {
129+
$cursor++;
112130
}
113131

114-
if ($index < $count) {
115-
$resolved[] = $positional[$index++];
132+
if ($cursor < $total) {
133+
$slot[$index] = $positional[$cursor];
134+
$used[$cursor] = true;
135+
$cursor++;
116136
} elseif ($param->isDefaultValueAvailable()) {
117-
$resolved[] = $param->getDefaultValue();
137+
$slot[$index] = $param->getDefaultValue();
118138
} else {
119-
$resolved[] = null;
139+
$slot[$index] = null;
140+
}
141+
}
142+
143+
// Assemble the fixed parameters in declaration order (plain array reads, no reflection),
144+
// then expand a trailing variadic from whatever named element and positional arguments
145+
// remain unconsumed.
146+
$resolved = [];
147+
foreach (array_keys($parameters) as $index) {
148+
$resolved[] = $slot[$index];
149+
}
150+
151+
if ($variadic !== null) {
152+
$name = $variadic->getName();
153+
if (array_key_exists($name, $named)) {
154+
$resolved[] = $named[$name];
155+
}
156+
157+
foreach ($positional as $i => $value) {
158+
if ($used[$i] ?? false) {
159+
continue;
160+
}
161+
162+
$resolved[] = $value;
120163
}
121164
}
122165

@@ -161,6 +204,29 @@ public static function acceptsType(ReflectionFunctionAbstract $reflection, strin
161204
return false;
162205
}
163206

207+
/**
208+
* Index of the first positional argument not yet consumed, optionally constrained to one whose
209+
* value is an instance of the given type. Returns null when no such argument remains.
210+
*
211+
* @param list<mixed> $positional
212+
* @param array<int, bool> $used
213+
* @param class-string|null $type
214+
*/
215+
private static function firstUnused(array $positional, array $used, string|null $type): int|null
216+
{
217+
foreach ($positional as $i => $value) {
218+
if ($used[$i] ?? false) {
219+
continue;
220+
}
221+
222+
if ($type === null || $value instanceof $type) {
223+
return $i;
224+
}
225+
}
226+
227+
return null;
228+
}
229+
164230
/** @return class-string|null */
165231
private static function typeName(ReflectionParameter $param): string|null
166232
{

tests/unit/ContainerResolverTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use PHPUnit\Framework\Attributes\CoversClass;
1414
use PHPUnit\Framework\Attributes\Test;
1515
use PHPUnit\Framework\TestCase;
16+
use Psr\Container\ContainerInterface;
1617
use ReflectionClass;
1718
use ReflectionFunction;
1819
use ReflectionMethod;
@@ -43,6 +44,65 @@ public function itShouldResolveByType(): void
4344
self::assertSame([$service, 'hello', 42], $args);
4445
}
4546

47+
#[Test]
48+
public function itShouldResolveByTypeDespiteOrder(): void
49+
{
50+
$service = new SampleService();
51+
$anotherService = new SampleService();
52+
$container = new ArrayContainer([
53+
SampleService::class => $service,
54+
'zoo' => $anotherService,
55+
]);
56+
$resolver = new ContainerResolver($container);
57+
58+
$args = $resolver->resolve($this->constructorOf(ServiceConsumer::class), ['hello', $container->get('zoo')]);
59+
60+
self::assertSame([$anotherService, 'hello', 42], $args);
61+
}
62+
63+
#[Test]
64+
public function itShouldResolveTypedArgumentDeclaredAfterScalar(): void
65+
{
66+
$service = new SampleService();
67+
$resolver = new ContainerResolver(new ArrayContainer());
68+
$fn = new ReflectionFunction(
69+
static fn(string $value, SampleService $service): array => [$value, $service],
70+
);
71+
72+
$args = $resolver->resolve($fn, [$service, 'hello']);
73+
74+
self::assertSame(['hello', $service], $args);
75+
}
76+
77+
#[Test]
78+
public function itShouldResolveMultipleTypedArgumentsOutOfOrder(): void
79+
{
80+
$service = new SampleService();
81+
$container = new ArrayContainer();
82+
$resolver = new ContainerResolver($container);
83+
$fn = new ReflectionFunction(
84+
static fn(SampleService $service, ContainerInterface $c, string $value): array => [$service, $c, $value],
85+
);
86+
87+
$args = $resolver->resolve($fn, ['hello', $container, $service]);
88+
89+
self::assertSame([$service, $container, 'hello'], $args);
90+
}
91+
92+
#[Test]
93+
public function itShouldNotConsumeScalarForUnfilledTypedParameter(): void
94+
{
95+
$service = new SampleService();
96+
$resolver = new ContainerResolver(new ArrayContainer());
97+
$fn = new ReflectionFunction(
98+
static fn(int $number, SampleService $service, string $value): array => [$number, $service, $value],
99+
);
100+
101+
$args = $resolver->resolve($fn, [7, 'hello', $service]);
102+
103+
self::assertSame([7, $service, 'hello'], $args);
104+
}
105+
46106
#[Test]
47107
public function itShouldAllowUserOverride(): void
48108
{

0 commit comments

Comments
 (0)