Skip to content

Commit 02afafd

Browse files
committed
PartialCall supports argument placeholders (PHP 8.6 partial application)
1 parent 1aa9625 commit 02afafd

3 files changed

Lines changed: 96 additions & 13 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* This file is part of the Nette Framework (https://nette.org)
5+
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6+
*/
7+
8+
namespace Nette\DI\Expressions;
9+
10+
11+
/**
12+
* Placeholder for an unbound argument in a partial function application (PHP 8.6+).
13+
*/
14+
enum ArgumentPlaceholder
15+
{
16+
case Single; // ? a single deferred argument
17+
case Variadic; // ... the remaining arguments
18+
}

src/DI/Expressions/PartialCall.php

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@
1212
use Nette\DI\Resolver;
1313
use Nette\DI\ServiceCreationException;
1414
use Nette\PhpGenerator as Php;
15-
use function class_exists, is_string, method_exists, sprintf, str_starts_with;
15+
use function array_keys, array_map, class_exists, implode, is_string, method_exists, sprintf, str_starts_with;
1616

1717

1818
/**
19-
* Partial function application, i.e. func(...), Class::method(...) or $object->method(...).
19+
* Partial function application, i.e. func(...), Class::method(?, $bound) or $object->method(...).
2020
*/
2121
final class PartialCall extends Expression
2222
{
2323
public function __construct(
2424
public readonly Expression|string|null $target,
2525
public readonly string $name,
26+
/** @var array<mixed> bound values (may be @references / expressions) interleaved with ArgumentPlaceholder markers */
27+
public readonly array $arguments = [ArgumentPlaceholder::Variadic],
2628
) {
2729
}
2830

@@ -35,7 +37,10 @@ public function resolveType(Resolver $resolver): string
3537

3638
public function complete(Resolver $resolver): self
3739
{
38-
if (is_string($this->target) && !Php\Helpers::isNamespaceIdentifier($this->target)) {
40+
if (!array_filter($this->arguments, fn($arg): bool => $arg instanceof ArgumentPlaceholder)) {
41+
throw new ServiceCreationException(sprintf('First-class callable %s must contain at least one placeholder (? or ...).', $this->usedIn($this->target)));
42+
43+
} elseif (is_string($this->target) && !Php\Helpers::isNamespaceIdentifier($this->target)) {
3944
throw new ServiceCreationException(sprintf("Expected a valid class name, '%s' given.", $this->target));
4045

4146
} elseif ($this->target === null
@@ -62,32 +67,56 @@ public function complete(Resolver $resolver): self
6267
}
6368
}
6469

65-
return $this->target instanceof Expression
66-
? new self($this->target->complete($resolver), $this->name)
67-
: $this;
70+
$target = $this->target instanceof Expression
71+
? $this->target->complete($resolver)
72+
: $this->target;
73+
74+
$arguments = $resolver->resolveArguments($this->arguments, $this->usedIn($target));
75+
return new self($target, $this->name, $arguments);
6876
}
6977

7078

7179
public function generateCode(PhpGenerator $generator): string
7280
{
81+
$args = implode(', ', array_map(
82+
fn($key, $arg): string => (is_string($key) ? "$key: " : '') . match ($arg) {
83+
ArgumentPlaceholder::Single => '?',
84+
ArgumentPlaceholder::Variadic => '...',
85+
default => $generator->formatPhp('?', [$arg]),
86+
},
87+
array_keys($this->arguments),
88+
$this->arguments,
89+
));
90+
7391
if ($this->target instanceof Expression) {
7492
$inner = $this->target->generateCode($generator);
7593
if (str_starts_with($inner, 'new ')) {
7694
$inner = "($inner)";
7795
}
7896

79-
return "$inner->$this->name(...)";
97+
return "$inner->$this->name($args)";
8098
}
8199

82100
return $this->target === null
83-
? "$this->name(...)"
84-
: "$this->target::$this->name(...)";
101+
? "$this->name($args)"
102+
: "$this->target::$this->name($args)";
85103
}
86104

87105

88106
public function transformValues(callable $cb): static
89107
{
90108
$name = $cb($this->name);
91-
return new self($cb($this->target), is_string($name) ? $name : $this->name);
109+
return new self($cb($this->target), is_string($name) ? $name : $this->name, $cb($this->arguments));
110+
}
111+
112+
113+
/** Human-readable callee for error messages, e.g. Foo::bar(), @svc::bar() or bar(). */
114+
private function usedIn(Expression|string|null $target): string
115+
{
116+
return match (true) {
117+
$target instanceof Reference => '@' . $target->getValue() . '::',
118+
is_string($target) => $target,
119+
default => '',
120+
} . $this->name . '()';
92121
}
93122
}

tests/Expressions/PartialCall.phpt

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Nette\DI;
88
use Nette\DI\Definitions\Statement;
9+
use Nette\DI\Expressions\ArgumentPlaceholder;
910
use Nette\DI\Expressions\PartialCall;
1011
use Nette\DI\Expressions\Reference;
1112
use Tester\Assert;
@@ -52,15 +53,44 @@ test('generateCode(): all target forms', function () {
5253
});
5354

5455

55-
test('complete(): valid callables pass and return the same instance', function () {
56+
test('generateCode(): partial application with bound arguments and placeholders (PHP 8.6+)', function () {
57+
[, $generator] = harness();
58+
// single ? placeholders and a bound value
59+
Assert::same("str_replace(?, ?, 'x')", (new PartialCall(null, 'str_replace', [ArgumentPlaceholder::Single, ArgumentPlaceholder::Single, 'x']))->generateCode($generator));
60+
// bound value then placeholder
61+
Assert::same('trim(1, ?)', (new PartialCall(null, 'trim', [1, ArgumentPlaceholder::Single]))->generateCode($generator));
62+
// placeholder then variadic rest
63+
Assert::same('Subject::pub(?, ...)', (new PartialCall('Subject', 'pub', [ArgumentPlaceholder::Single, ArgumentPlaceholder::Variadic]))->generateCode($generator));
64+
// named arguments
65+
Assert::same('trim(string: ?, characters: 1)', (new PartialCall(null, 'trim', ['string' => ArgumentPlaceholder::Single, 'characters' => 1]))->generateCode($generator));
66+
// placeholder on a method call
67+
Assert::same("\$this->getService('a')->pub(?)", (new PartialCall(new Reference('a'), 'pub', [ArgumentPlaceholder::Single]))->generateCode($generator));
68+
});
69+
70+
71+
test('complete(): bound @service arguments are resolved, placeholders pass through untouched', function () {
72+
[$resolver, $generator] = harness();
73+
$partial = new PartialCall(null, 'str_replace', ['@a', ArgumentPlaceholder::Single, ArgumentPlaceholder::Variadic]);
74+
$completed = $partial->complete($resolver);
75+
76+
Assert::notSame($partial, $completed);
77+
Assert::same('@a', $partial->arguments[0]); // original untouched
78+
Assert::type(Reference::class, $completed->arguments[0]); // @a resolved
79+
Assert::same(ArgumentPlaceholder::Single, $completed->arguments[1]); // placeholder kept
80+
Assert::same(ArgumentPlaceholder::Variadic, $completed->arguments[2]); // placeholder kept
81+
Assert::same("str_replace(\$this->getService('a'), ?, ...)", $completed->generateCode($generator));
82+
});
83+
84+
85+
test('complete(): valid callables pass and stay unchanged', function () {
5686
[$resolver] = harness();
5787
foreach ([
5888
new PartialCall(null, 'trim'),
5989
new PartialCall(null, 'Foo\undefinedFunc'), // function existence is not verified (not autoloadable)
6090
new PartialCall(Subject::class, 'pub'),
6191
new PartialCall(Subject::class, 'magic'), // missing method tolerated because of __callStatic
6292
] as $callable) {
63-
Assert::same($callable, $callable->complete($resolver));
93+
Assert::equal($callable, $callable->complete($resolver));
6494
}
6595
});
6696

@@ -111,9 +141,15 @@ testException('complete(): reference to missing service inside target', function
111141
}, DI\ServiceCreationException::class, "Reference to missing service 'missing'.");
112142

113143

144+
testException('complete(): plain arguments without any placeholder are rejected', function () {
145+
[$resolver] = harness();
146+
(new PartialCall(null, 'trim', [1, 2]))->complete($resolver);
147+
}, DI\ServiceCreationException::class, 'First-class callable trim() must contain at least one placeholder (? or ...).');
148+
149+
114150
test('transformValues(): callback is applied to target and name, original is untouched', function () {
115151
$callable = new PartialCall('%class%', '%method%');
116-
$transformed = $callable->transformValues(fn($v) => strtr($v, ['%class%' => 'Subject', '%method%' => 'pub']));
152+
$transformed = $callable->transformValues(fn($v) => is_string($v) ? strtr($v, ['%class%' => 'Subject', '%method%' => 'pub']) : $v);
117153
Assert::notSame($callable, $transformed);
118154
Assert::same('Subject', $transformed->target);
119155
Assert::same('pub', $transformed->name);

0 commit comments

Comments
 (0)