From 1e94adc872ac20b926ea665f7ad690922760b533 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 22:07:36 +0100 Subject: [PATCH] add compilable decorators --- composer.json | 3 +- src/Collections/Internal/functions.php | 33 ++++++ src/Compilable.php | 21 ++++ src/Utils/FixedFunction.php | 53 ++++++++- src/Utils/PartiallyAppliedFunction.php | 26 ++++- src/Utils/index.php | 9 ++ tests/Collections/CallbackTest.php | 47 ++++++++ tests/CompileTest.php | 149 +++++++++++++++++++++++++ 8 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 src/Collections/Internal/functions.php create mode 100644 src/Compilable.php create mode 100644 tests/Collections/CallbackTest.php create mode 100644 tests/CompileTest.php diff --git a/composer.json b/composer.json index 516ae5f..048a906 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ }, "files": [ "./src/functions.php", - "./src/Predicates/index.php" + "./src/Predicates/index.php", + "./src/Collections/Internal/functions.php" ] }, "suggest": { diff --git a/src/Collections/Internal/functions.php b/src/Collections/Internal/functions.php new file mode 100644 index 0000000..4abf051 --- /dev/null +++ b/src/Collections/Internal/functions.php @@ -0,0 +1,33 @@ + + * + * Full license available in separate LICENSE file + */ + +namespace Kadet\Functional\Collections\Internal; + +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. + * + * @internal + */ +function callback(callable $callable): \Closure +{ + $compiled = compile($callable); + $reflection = new \ReflectionFunction($compiled); + + if ($reflection->isInternal() || (!$reflection->isVariadic() && $reflection->getNumberOfParameters() === 1)) { + return static fn ($value) => $compiled($value); + } + + return $compiled; +} diff --git a/src/Compilable.php b/src/Compilable.php new file mode 100644 index 0000000..e35bc5c --- /dev/null +++ b/src/Compilable.php @@ -0,0 +1,21 @@ + + * + * Full license available in separate LICENSE file + */ + +namespace Kadet\Functional; + +/** + * A callable object that can be flattened into a plain closure. + */ +interface Compilable +{ + /** + * Returns a closure that behaves exactly like invoking this object, but does not go through it on each call. + */ + public function compile(): \Closure; +} diff --git a/src/Utils/FixedFunction.php b/src/Utils/FixedFunction.php index 5cc38c9..dc2d92b 100644 --- a/src/Utils/FixedFunction.php +++ b/src/Utils/FixedFunction.php @@ -11,9 +11,13 @@ namespace Kadet\Functional\Utils; use function Kadet\Functional\apply; +use Kadet\Functional\Compilable; +use function Kadet\Functional\compile; use Kadet\Functional\Decorator; +use function Kadet\Functional\partial; +use function Kadet\Functional\reflect; -class FixedFunction implements Decorator +class FixedFunction implements Decorator, Compilable { /** * @var callable @@ -39,4 +43,51 @@ class FixedFunction implements Decorator { return $this->fixed; } + + /** + * The compiled closure passes itself as the self-reference, so recursive calls stay compiled too. It applies + * arguments like apply(), with the fixed function reflected once here instead of on every call. + */ + public function compile(): \Closure + { + $fixed = $this->fixed; + + try { + $reflection = reflect($fixed); + } catch (\ReflectionException $exception) { + $self = static function (...$args) use (&$self, $fixed) { + return $fixed($self, ...$args); + }; + + return $self; + } + + $variadic = $reflection->isVariadic(); + $required = $reflection->getNumberOfRequiredParameters(); + $all = $reflection->getNumberOfParameters(); + + // A curried function decides on its own whether to call or keep currying, so it is kept as is. + $call = $fixed instanceof CurriedFunction ? $fixed : compile($fixed); + + $self = static function (...$args) use (&$self, $fixed, $call, $variadic, $required, $all) { + array_unshift($args, $self); + + switch (true) { + case $variadic: + return partial($fixed, ...$args); + + case count($args) >= $all: + $applied = $call(...array_slice($args, 0, $all)); + return is_callable($applied) ? apply($applied, ...array_slice($args, $all)) : $applied; + + case count($args) >= $required: + return $call(...$args); + + default: + return partial($fixed, ...$args); + } + }; + + return $self; + } } \ No newline at end of file diff --git a/src/Utils/PartiallyAppliedFunction.php b/src/Utils/PartiallyAppliedFunction.php index 29af323..279e5c6 100644 --- a/src/Utils/PartiallyAppliedFunction.php +++ b/src/Utils/PartiallyAppliedFunction.php @@ -10,11 +10,13 @@ namespace Kadet\Functional\Utils; use const Kadet\Functional\_ as PLACEHOLDER; +use Kadet\Functional\Compilable; use Kadet\Functional\Decorator; +use function Kadet\Functional\compile; use function Kadet\Functional\not; use function Kadet\Functional\Predicates\same; -class PartiallyAppliedFunction implements Decorator +class PartiallyAppliedFunction implements Decorator, Compilable { protected $callable; protected $arguments = []; @@ -64,6 +66,28 @@ class PartiallyAppliedFunction implements Decorator return $this->callable; } + public function compile(): \Closure + { + $callable = compile($this->callable); + $arguments = $this->arguments; + $placeholders = array_keys($arguments, PLACEHOLDER, true); + + if (empty($placeholders)) { + return static fn (...$args) => $callable(...$arguments, ...array_values($args)); + } + + return static function (...$args) use ($callable, $arguments, $placeholders) { + $args = array_values($args); + $count = min(count($placeholders), count($args)); + + for ($i = 0; $i < $count; $i++) { + $arguments[$placeholders[$i]] = $args[$i]; + } + + return $callable(...$arguments, ...array_slice($args, $count)); + }; + } + public static function fix(callable $callable, ...$args) { $applied = new self($callable, ...$args); diff --git a/src/Utils/index.php b/src/Utils/index.php index fa9ce3f..af87561 100644 --- a/src/Utils/index.php +++ b/src/Utils/index.php @@ -71,4 +71,13 @@ function fix(callable $callable, ...$args) function memoize(callable $callable) { return new MemoizedFunction($callable); +} + +/** + * Flattens a callable into a plain closure. Compilable decorators are compiled, so calling the result does not go + * through them; any other callable is converted with closure(). + */ +function compile(callable $callable): \Closure +{ + return $callable instanceof Compilable ? $callable->compile() : closure($callable); } \ No newline at end of file diff --git a/tests/Collections/CallbackTest.php b/tests/Collections/CallbackTest.php new file mode 100644 index 0000000..01bc8ce --- /dev/null +++ b/tests/Collections/CallbackTest.php @@ -0,0 +1,47 @@ + + * + * Full license available in separate LICENSE file + */ + +use Kadet\Functional as f; +use function Kadet\Functional\Collections\Internal\callback; +use const Kadet\Functional\_; + +class CallbackTest extends \PHPUnit\Framework\TestCase +{ + public function testInternalFunctionGetsValueOnly() + { + $this->assertSame('a', callback(trim(...))(' a ', 'xyz')); + } + + public function testInternalStringCallableGetsValueOnly() + { + $this->assertSame('A', callback('strtoupper')('a', 'key')); + } + + public function testSingleParamClosureGetsValueOnly() + { + $this->assertSame([1], callback(function ($v) { return func_get_args(); })(1, 'k')); + } + + public function testTwoParamClosureGetsKey() + { + $this->assertSame('k=v', callback(fn ($v, $k) => "$k=$v")('v', 'k')); + } + + public function testVariadicClosureGetsKey() + { + $this->assertSame(['v', 'k'], callback(fn (...$args) => $args)('v', 'k')); + } + + public function testDecoratorIsCompiled() + { + $callback = callback(f\partial(fn ($prefix, $v, $k) => "$prefix$k=$v", '>')); + + $this->assertSame('>k=v', $callback('v', 'k')); + } +} diff --git a/tests/CompileTest.php b/tests/CompileTest.php new file mode 100644 index 0000000..ab88f4d --- /dev/null +++ b/tests/CompileTest.php @@ -0,0 +1,149 @@ + + * + * Full license available in separate LICENSE file + */ + +use Kadet\Functional as f; +use Kadet\Functional\Compilable; +use Kadet\Functional\Decorator; +use Kadet\Functional\Utils\PartiallyAppliedFunction; +use const Kadet\Functional\_; + +class CompileTest extends \PHPUnit\Framework\TestCase +{ + public function testCompileReturnsClosureForPartial() + { + $compiled = f\partial(str_replace(...), 'a', _, 'banana')->compile(); + + $this->assertInstanceOf(\Closure::class, $compiled); + $this->assertNotInstanceOf(Decorator::class, $compiled); + $this->assertSame('bxnxnx', $compiled('x')); + } + + public function testCompiledPartialDoesNotInvokeDecorator() + { + $spy = new class(fn ($a, $b) => $a . $b, 'a') extends PartiallyAppliedFunction { + public int $calls = 0; + + public function __invoke(...$args) + { + $this->calls++; + return parent::__invoke(...$args); + } + + protected function prepareArguments($args) + { + $this->calls++; + return parent::prepareArguments($args); + } + }; + + $compiled = $spy->compile(); + + $this->assertSame('ab', $compiled('b')); + $this->assertSame(0, $spy->calls); + } + + public function testCompileMatchesDecoratorForCurried() + { + $curried = f\curry(fn ($a, $b, $c) => "$a$b$c"); + $applied = $curried('a'); + $compiled = $applied->compile(); + + $this->assertSame('abc', $compiled('b', 'c')); + $this->assertSame($applied('b', 'c'), $compiled('b', 'c')); + } + + public function testCompiledCurriedDoesNotCurryFurther() + { + $applied = f\curry(fn ($a, $b = 'B', $c = 'C') => "$a$b$c", 3, 'a'); + + $this->assertSame($applied->call('b'), $applied->compile()('b')); + } + + public function testCompileNestedPartial() + { + $f3 = fn ($a, $b, $c) => [$a, $b, $c]; + + $this->assertSame($f3(1, 2, 3), f\compile(f\partial(f\partial($f3, 1), _, 3))(2)); + } + + public function testCompileAppendsExtraArguments() + { + $partial = f\partial(fn (...$args) => $args, 1, _, 3); + + $this->assertSame($partial(2, 4, 5), f\compile($partial)(2, 4, 5)); + $this->assertSame([1, 2, 3, 4, 5], f\compile($partial)(2, 4, 5)); + } + + public function testCompileKeepsUnfilledPlaceholders() + { + $partial = f\partial(fn (...$args) => $args, _, 2, _); + + $this->assertSame($partial(1), f\compile($partial)(1)); + $this->assertSame($partial(), f\compile($partial)()); + } + + public function testCompileFunctionDelegatesToMethod() + { + $partial = f\partial(str_replace(...), 'a', _, 'banana'); + + $this->assertSame($partial->compile()('o'), f\compile($partial)('o')); + } + + public function testCompilePassesThroughClosures() + { + $closure = fn ($x) => $x; + + $this->assertSame($closure, f\compile($closure)); + } + + public function testCompileConvertsCallableToClosure() + { + $compiled = f\compile('strtoupper'); + + $this->assertInstanceOf(\Closure::class, $compiled); + $this->assertSame('ABC', $compiled('abc')); + } + + public function testDecoratorsAreCompilable() + { + $this->assertInstanceOf(Compilable::class, f\partial('strtoupper')); + $this->assertInstanceOf(Compilable::class, f\curry('strtoupper')); + $this->assertInstanceOf(Compilable::class, f\fix(fn ($f) => $f)); + } + + public function testCompiledFixedFunctionReferencesItself() + { + $compiled = f\fix(function ($f) use (&$compiled) { + $this->assertSame($f, $compiled); + + return fn ($x) => $x; + })->compile(); + + $this->assertSame(1, $compiled(1)); + } + + public function testCompiledFixedFunctionRecurses() + { + $compiled = f\compile(f\fix(function ($f) { + return function ($x) use ($f) { return $x >= 2 ? $f($x - 1) * $x : 1; }; + })); + + $this->assertInstanceOf(\Closure::class, $compiled); + $this->assertSame(6, $compiled(3)); + $this->assertSame(24, $compiled(4)); + } + + public function testCompiledFixedCurriedFunction() + { + $compiled = f\compile(f\fix(f\curry(function ($f, $x) { return $x >= 2 ? $f($x - 1) * $x : 1; }))); + + $this->assertSame(6, $compiled(3)); + $this->assertSame(24, $compiled(4)); + } +}