From 159e9a56aba2c1023c6e971021a718a2e19bc468 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 22:11:15 +0100 Subject: [PATCH] pass value only to decorated internal callbacks --- src/Collections/Internal/functions.php | 36 +++++++++++++++++++++++--- src/Compilable.php | 5 +++- tests/Collections/CallbackTest.php | 15 +++++++++++ tests/CompileTest.php | 16 ++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/Collections/Internal/functions.php b/src/Collections/Internal/functions.php index 4abf051..0a75ac4 100644 --- a/src/Collections/Internal/functions.php +++ b/src/Collections/Internal/functions.php @@ -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; + } +} diff --git a/src/Compilable.php b/src/Compilable.php index e35bc5c..74a50a6 100644 --- a/src/Compilable.php +++ b/src/Compilable.php @@ -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; } diff --git a/tests/Collections/CallbackTest.php b/tests/Collections/CallbackTest.php index e0481df..01e6575 100644 --- a/tests/Collections/CallbackTest.php +++ b/tests/Collections/CallbackTest.php @@ -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", '>')); diff --git a/tests/CompileTest.php b/tests/CompileTest.php index 1339857..44e4874 100644 --- a/tests/CompileTest.php +++ b/tests/CompileTest.php @@ -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');