pass value only to decorated internal callbacks

This commit is contained in:
Kacper Donat 2026-10-27 22:11:15 +01:00
parent 66705f3e31
commit 159e9a56ab
4 changed files with 68 additions and 4 deletions

View File

@ -9,14 +9,16 @@
namespace Kadet\Functional\Collections\Internal;
use Kadet\Functional\Decorator;
use function Kadet\Functional\closure;
use function Kadet\Functional\compile;
/**
* Compiles a collection callback, adapted to its arity.
*
* The returned closure is called with ($value, $key). Internal functions (including first-class callables like
* `trim(...)`) and non-variadic closures declaring exactly one parameter receive only the value, so the key is
* never mistaken for an optional argument.
* `trim(...)`, also when wrapped in decorators) and non-variadic closures declaring exactly one parameter receive
* only the value, so the key is never mistaken for an optional argument.
*
* @internal
*/
@ -24,10 +26,38 @@ function callback(callable $callable): \Closure
{
$compiled = compile($callable);
$reflection = new \ReflectionFunction($compiled);
$target = new \ReflectionFunction(closure(target($callable)));
if ($reflection->isInternal() || (!$reflection->isVariadic() && $reflection->getNumberOfParameters() === 1)) {
if ($target->isInternal() || (!$reflection->isVariadic() && $reflection->getNumberOfParameters() === 1)) {
return static fn ($value) => $compiled($value);
}
return $compiled;
}
/**
* Finds the function a callable ultimately calls, by unwrapping decorators, also when they were turned into closures.
*
* @internal
*/
function target(callable $callable): callable
{
while (true) {
if ($callable instanceof Decorator) {
$callable = $callable->getDecorated();
continue;
}
if ($callable instanceof \Closure) {
$reflection = new \ReflectionFunction($callable);
$bound = $reflection->getClosureThis();
if ($bound instanceof Decorator && $reflection->getName() === '__invoke') {
$callable = $bound;
continue;
}
}
return $callable;
}
}

View File

@ -15,7 +15,10 @@ namespace Kadet\Functional;
interface Compilable
{
/**
* Returns a closure that behaves exactly like invoking this object, but does not go through it on each call.
* Returns a closure that behaves like invoking this object, but does not go through it on each call.
*
* The one difference: when the call ends in a non-variadic internal function, arguments past its parameter count
* are dropped, where invoking this object would throw an ArgumentCountError.
*/
public function compile(): \Closure;
}

View File

@ -43,6 +43,21 @@ class CallbackTest extends \PHPUnit\Framework\TestCase
$this->assertSame('a', callback(f\partial(trim(...), _, 'x'))('xax', 'key'));
}
public function testPartialOfInternalFunctionDoesNotFillOptionalParameterWithKey()
{
$this->assertSame(['a', 'b'], callback(f\partial(explode(...), ','))('a,b', 0));
}
public function testPartialOfInternalFunctionWithoutArgumentsGetsValueOnly()
{
$this->assertSame('a', callback(f\partial(trim(...)))(' a ', 'k'));
}
public function testCompiledMemoizedInternalFunctionGetsValueOnly()
{
$this->assertSame('a', callback(f\compile(f\memoize(trim(...))))(' a ', 'k'));
}
public function testDecoratorIsCompiled()
{
$callback = callback(f\partial(fn ($prefix, $v, $k) => "$prefix$k=$v", '>'));

View File

@ -103,6 +103,22 @@ class CompileTest extends \PHPUnit\Framework\TestCase
$this->assertSame('aa', f\compile(f\curry(str_repeat(...), null, 'a'))(2, 'extra'));
}
public function testCompileWithFewerArgumentsThanPlaceholdersMatchesDecorator()
{
$partial = f\partial(str_repeat(...), _, _);
$this->assertSame($this->outcome(fn () => $partial('a')), $this->outcome(fn () => f\compile($partial)('a')));
}
private function outcome(callable $call)
{
try {
return ['result', $call()];
} catch (\Throwable $error) {
return ['error', get_class($error), $error->getMessage()];
}
}
public function testCompileFunctionDelegatesToMethod()
{
$partial = f\partial(str_replace(...), 'a', _, 'banana');