Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 74 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ composer require respect/parameter

## Usage

The package offers two contracts with different guarantees:

- `Resolver` **completes a call**: it returns a full argument list keyed by
parameter name, padding gaps with container services, defaults, or `null`.
Implemented by `ContainerResolver`.
- `Augmenter` **assists a factory**: the given arguments stay authoritative —
never rebound, reordered, or padded — and the container only fills genuine
gaps. Implemented by `ContainerAugmenter`.

Type-hint the interfaces to keep implementations swappable and testable.

### Resolve from a container

For each parameter the resolver tries, in order:
Expand All @@ -21,13 +32,13 @@ For each parameter the resolver tries, in order:
5. `null`

```php
use Respect\Parameter\Resolver;
use Respect\Parameter\ContainerResolver;

function notify(Mailer $mailer, Logger $logger, string $to, string $subject = 'Hi') {
// ...
}

$resolver = new Resolver($container);
$resolver = new ContainerResolver($container);
$args = $resolver->resolve(new ReflectionFunction('notify'), ['bob@example.com']);
// ['mailer' => Mailer, 'logger' => Logger, 'to' => 'bob@example.com', 'subject' => 'Hi']
```
Expand All @@ -50,34 +61,82 @@ $args = $resolver->resolveNamed(
// Named args take precedence, gaps filled from container by name and type
```

### Augment arguments

Use the augmenter when the arguments must stay exactly as the caller provided
them (e.g. factories that pass user input straight to a constructor) and the
container should only supply the missing services:

```php
use Respect\Parameter\ContainerAugmenter;

final class Notifier
{
public function __construct(
private string $channel,
private Mailer|null $mailer = null,
) {
}
}

$augmenter = new ContainerAugmenter($container);
$args = $augmenter->augment($constructor, ['slack']);
// ['slack', 'mailer' => Mailer] — positional args untouched, gaps named
```

Variadic, builtin-typed, and already-filled parameters are never augmented.
Extra arguments (e.g. for variadic parameters) pass through unchanged, and
missing arguments are never padded with defaults or `null`.

#### Unresolvable types

Value-like classes should never be served by the container, even when it can
provide them — a container-cached `DateTimeImmutable` is a frozen clock.
List them at construction to exclude them from container lookups:

```php
$augmenter = new ContainerAugmenter($container, [
DateTimeImmutable::class,
DateTimeInterface::class,
]);
```

### Reflect any callable

Convert any callable form into a `ReflectionFunctionAbstract`:

```php
use Respect\Parameter\Resolver;
use Respect\Parameter\Reflector;

Resolver::reflectCallable(fn() => ...); // Closure
Resolver::reflectCallable([$obj, 'method']); // Array callable
Resolver::reflectCallable(new Invocable()); // __invoke object
Resolver::reflectCallable('strlen'); // Function name
Resolver::reflectCallable('DateTime::createFromFormat'); // Static method
Reflector::reflectCallable(fn() => ...); // Closure
Reflector::reflectCallable([$obj, 'method']); // Array callable
Reflector::reflectCallable(new Invocable()); // __invoke object
Reflector::reflectCallable('strlen'); // Function name
Reflector::reflectCallable('DateTime::createFromFormat'); // Static method
```

### Check accepted types

```php
Resolver::acceptsType($reflection, LoggerInterface::class); // true/false
Reflector::acceptsType($reflection, LoggerInterface::class); // true/false
```

## API

| Method | Type | Description |
|-----------------------------------------|----------|------------------------------------------------------|
| `resolve($reflection, $positional)` | instance | Resolve parameters from positional args + container. Returns `array<string, mixed>` keyed by parameter name |
| `resolveNamed($reflection, $named)` | instance | Resolve from named args (priority) + container. Returns `array<string, mixed>` keyed by parameter name |
| `reflectCallable($callable)` | static | Any callable to `ReflectionFunctionAbstract` |
| `acceptsType($reflection, $type)` | static | Check if any parameter accepts a type |
| Method | Defined on | Description |
|---------------------------------------------|-------------|------------------------------------------------------|
| `resolve($reflection, $positional)` | `Resolver` | Resolve parameters from positional args + container. Returns `array<string, mixed>` keyed by parameter name |
| `resolveNamed($reflection, $named)` | `Resolver` | Resolve from named args (priority) + container. Returns `array<string, mixed>` keyed by parameter name |
| `augment($reflection, $args)` | `Augmenter` | Fill only unfilled parameters from the container; given args are never rebound, reordered, or padded |
| `Reflector::reflectCallable($callable)` | `Reflector` | Any callable to `ReflectionFunctionAbstract` |
| `Reflector::acceptsType($reflection, $type)`| `Reflector` | Check if any parameter accepts a type |

## Upgrading from 1.x

- `Resolver` is now an interface; the concrete class is `ContainerResolver`.
- `Resolver::reflectCallable()` and `Resolver::acceptsType()` moved to `Reflector`.
- The new `Augmenter`/`ContainerAugmenter` fill unfilled parameters without
touching the given arguments.

## License

Expand Down
10 changes: 9 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
"psr-4": {
"Respect\\Parameter\\Test\\": "tests/",
"Respect\\Parameter\\Test\\Fixtures\\": "tests/fixtures"
}
},
"files": [
"tests/fixtures/functions.php"
]
},
"scripts": {
"phpcs": "vendor/bin/phpcs",
Expand All @@ -43,6 +46,11 @@
"@phpunit"
]
},
"extra": {
"branch-alias": {
"dev-main": "2.0.x-dev"
}
},
"config": {
"sort-packages": true,
"allow-plugins": {
Expand Down
30 changes: 30 additions & 0 deletions src/Augmenter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Parameter;

use ReflectionFunctionAbstract;

interface Augmenter
{
/**
* Augment the given arguments with values for the parameters they do not already fill.
*
* The given arguments are authoritative: they are never rebound, reordered,
* or padded with defaults or null. Only parameters left unfilled may gain a
* value, added as named arguments. Variadic and builtin-typed parameters
* are never augmented.
*
* @param array<int|string, mixed> $arguments Positional and/or named arguments
*
* @return array<int|string, mixed>
*/
public function augment(ReflectionFunctionAbstract $reflection, array $arguments): array;
}
126 changes: 126 additions & 0 deletions src/ContainerAugmenter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

declare(strict_types=1);

namespace Respect\Parameter;

use Psr\Container\ContainerInterface;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionNamedType;

use function array_filter;
use function array_is_list;
use function array_key_exists;
use function array_keys;
use function class_exists;
use function count;
use function in_array;
use function interface_exists;
use function is_int;

/**
* Augments arguments with services from a PSR-11 container.
*
* Types listed as unresolvable are never looked up in the container, which
* keeps value-like classes (clocks, dates) from being served as services.
*/
final class ContainerAugmenter implements Augmenter
{
/** @var array<string, list<array{int, string, class-string}>> */
private array $augmentableParametersCache = [];

/** @param array<class-string> $unresolvableTypes */
public function __construct(
private readonly ContainerInterface $container,
private readonly array $unresolvableTypes = [],
) {
}

/**
* @param array<int|string, mixed> $arguments Positional and/or named arguments
*
* @return array<int|string, mixed>
*/
public function augment(ReflectionFunctionAbstract $reflection, array $arguments): array
{
if (count($arguments) >= $reflection->getNumberOfParameters()) {
return $arguments;
}

$augmentableParameters = $this->augmentableParameters($reflection);
if ($augmentableParameters === []) {
return $arguments;
}

$positionalArgumentsCount = count(
array_is_list($arguments) ? $arguments : array_filter(array_keys($arguments), is_int(...)),
);

foreach ($augmentableParameters as [$position, $name, $type]) {
if ($position < $positionalArgumentsCount || array_key_exists($name, $arguments)) {
continue;
}

if (!$this->container->has($type)) {
continue;
}

$arguments[$name] = $this->container->get($type);
}

return $arguments;
}

/** @return list<array{int, string, class-string}> */
private function augmentableParameters(ReflectionFunctionAbstract $reflection): array
{
$cacheKey = self::createCacheKey($reflection);
if (isset($this->augmentableParametersCache[$cacheKey])) {
return $this->augmentableParametersCache[$cacheKey];
}

$parameters = [];
foreach ($reflection->getParameters() as $parameter) {
$type = $parameter->getType();
if ($parameter->isVariadic() || !$type instanceof ReflectionNamedType || $type->isBuiltin()) {
continue;
}

$typeName = $type->getName();
if (!class_exists($typeName) && !interface_exists($typeName)) {
continue;
}

if (in_array($typeName, $this->unresolvableTypes, true)) {
continue;
}

$parameters[] = [$parameter->getPosition(), $parameter->getName(), $typeName];
}

return $this->augmentableParametersCache[$cacheKey] = $parameters;
}

private static function createCacheKey(ReflectionFunctionAbstract $reflection): string
{
if ($reflection instanceof ReflectionMethod) {
return $reflection->class . '::' . $reflection->name;
}

if (!$reflection->isClosure()) {
return $reflection->name;
}

$file = $reflection->getFileName() ?: 'internal';
$line = $reflection->getStartLine() ?: 0;

return $reflection->getName() . '@' . $file . ':' . $line;
}
}
Loading