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; namespace Kadet\Functional\Collections\Internal;
use Kadet\Functional\Decorator;
use function Kadet\Functional\closure;
use function Kadet\Functional\compile; use function Kadet\Functional\compile;
/** /**
* Compiles a collection callback, adapted to its arity. * Compiles a collection callback, adapted to its arity.
* *
* The returned closure is called with ($value, $key). Internal functions (including first-class callables like * 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 * `trim(...)`, also when wrapped in decorators) and non-variadic closures declaring exactly one parameter receive
* never mistaken for an optional argument. * only the value, so the key is never mistaken for an optional argument.
* *
* @internal * @internal
*/ */
@ -24,10 +26,38 @@ function callback(callable $callable): \Closure
{ {
$compiled = compile($callable); $compiled = compile($callable);
$reflection = new \ReflectionFunction($compiled); $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 static fn ($value) => $compiled($value);
} }
return $compiled; 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 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; 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')); $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() public function testDecoratorIsCompiled()
{ {
$callback = callback(f\partial(fn ($prefix, $v, $k) => "$prefix$k=$v", '>')); $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')); $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() public function testCompileFunctionDelegatesToMethod()
{ {
$partial = f\partial(str_replace(...), 'a', _, 'banana'); $partial = f\partial(str_replace(...), 'a', _, 'banana');