diff --git a/composer.json b/composer.json index 0d2d1b2..cf52e0b 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,6 @@ "./src/functions.php", "./src/Internal/functions.php", "./src/Predicates/index.php", - "./src/Collections/Internal/functions.php", "./src/Collections/Internal/containers.php", "./src/Collections/Internal/queries.php", "./src/Collections/Lazy/transforms.php", diff --git a/src/Collections/Internal/functions.php b/src/Collections/Internal/functions.php deleted file mode 100644 index 0a75ac4..0000000 --- a/src/Collections/Internal/functions.php +++ /dev/null @@ -1,63 +0,0 @@ - - * - * Full license available in separate LICENSE file - */ - -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(...)`, 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 - */ -function callback(callable $callable): \Closure -{ - $compiled = compile($callable); - $reflection = new \ReflectionFunction($compiled); - $target = new \ReflectionFunction(closure(target($callable))); - - 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/Collections/Internal/queries.php b/src/Collections/Internal/queries.php index 0b07fd8..de157d4 100644 --- a/src/Collections/Internal/queries.php +++ b/src/Collections/Internal/queries.php @@ -9,6 +9,8 @@ namespace Kadet\Functional\Collections\Internal; +use function Kadet\Functional\Internal\callback; + use Kadet\Functional\Exception\EmptyCollectionException; /** diff --git a/src/Collections/Lazy/transforms.php b/src/Collections/Lazy/transforms.php index d9e4134..086af37 100644 --- a/src/Collections/Lazy/transforms.php +++ b/src/Collections/Lazy/transforms.php @@ -22,7 +22,7 @@ namespace Kadet\Functional\Collections\Lazy; -use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Internal\callback; use function Kadet\Functional\Collections\Internal\entries; use function Kadet\Functional\Collections\Internal\seen; use function Kadet\Functional\Collections\Internal\unpairs; diff --git a/src/Collections/collectors.php b/src/Collections/collectors.php index 3275d6d..5b1cd9c 100644 --- a/src/Collections/collectors.php +++ b/src/Collections/collectors.php @@ -17,7 +17,7 @@ namespace Kadet\Functional\Collections; use Kadet\Functional\Exception\DsUnavailableException; -use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Internal\callback; use function Kadet\Functional\Collections\Internal\dsAvailable; /** diff --git a/src/Collections/queries.php b/src/Collections/queries.php index d6f4f1f..0875ecd 100644 --- a/src/Collections/queries.php +++ b/src/Collections/queries.php @@ -21,7 +21,7 @@ namespace Kadet\Functional\Collections; use Kadet\Functional\Exception\EmptyCollectionException; -use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Internal\callback; use function Kadet\Functional\Collections\Internal\extremum; use function Kadet\Functional\Collections\Internal\firstOf; use function Kadet\Functional\Collections\Internal\reversedEntries; diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php index 63a4a9a..8f099f2 100644 --- a/src/Collections/transforms.php +++ b/src/Collections/transforms.php @@ -21,7 +21,7 @@ namespace Kadet\Functional\Collections; use Kadet\Functional\Collections\Lazy as lazy; -use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Internal\callback; use function Kadet\Functional\Collections\Internal\entries; use function Kadet\Functional\Collections\Internal\keyedLike; use function Kadet\Functional\Collections\Internal\listLike; diff --git a/src/Internal/functions.php b/src/Internal/functions.php index 125ed5c..7380c17 100644 --- a/src/Internal/functions.php +++ b/src/Internal/functions.php @@ -9,7 +9,12 @@ namespace Kadet\Functional\Internal; +use Kadet\Functional\Decorator; +use Kadet\Functional\Utils\FunctionPipeline; + use function Kadet\Functional\apply; +use function Kadet\Functional\closure; +use function Kadet\Functional\compile; use function Kadet\Functional\partial; /** @@ -37,3 +42,72 @@ function applyWithArity(callable $callable, callable $call, bool $variadic, int return partial($callable, ...$args); } } + +/** + * Compiles a callback that is called with `$arity` leading arguments (the value, or the carry and the value), + * followed by the key. + * + * Whether the key is passed is decided by the callback's target, the function it ultimately calls (see target()): + * an internal function, or a non-variadic one declaring exactly `$arity` parameters, gets only the leading + * arguments. This keeps the key out of optional parameters (e.g. trim's character list) and out of decorators that + * see all their arguments (e.g. memoize's cache key). + * + * @internal + * + * @param 1|2 $arity + */ +function callback(callable $callable, int $arity = 1): \Closure +{ + $compiled = compile($callable); + $target = new \ReflectionFunction(closure(target($callable))); + + $leadingOnly = $target->isInternal() + || (!$target->isVariadic() && $target->getNumberOfParameters() === $arity); + + if (!$leadingOnly) { + return $compiled; + } + + // A userland closure taking no more than the leading arguments already ignores the rest. + $reflection = new \ReflectionFunction($compiled); + if (!$reflection->isInternal() && !$reflection->isVariadic() && $reflection->getNumberOfParameters() <= $arity) { + return $compiled; + } + + return $arity === 1 + ? static fn ($value) => $compiled($value) + : static fn ($carry, $value) => $compiled($carry, $value); +} + +/** + * Finds the function a callable ultimately calls: decorators are unwrapped, also when they were turned into + * closures, and a pipeline gives its first function, which gets all of its arguments. + * + * @internal + */ +function target(callable $callable): callable +{ + while (true) { + if ($callable instanceof Decorator) { + $callable = $callable->getDecorated(); + continue; + } + + if ($callable instanceof FunctionPipeline && $callable->first() !== null) { + $callable = $callable->first(); + continue; + } + + if ($callable instanceof \Closure) { + $reflection = new \ReflectionFunction($callable); + $bound = $reflection->getClosureThis(); + + if (($bound instanceof Decorator || $bound instanceof FunctionPipeline) && $reflection->getName() === '__invoke') { + $callable = $bound; + continue; + } + } + + return $callable; + } +} diff --git a/src/Predicate/AllOfPredicate.php b/src/Predicate/AllOfPredicate.php index 033d3a0..e554b3c 100644 --- a/src/Predicate/AllOfPredicate.php +++ b/src/Predicate/AllOfPredicate.php @@ -13,7 +13,7 @@ namespace Kadet\Functional\Predicate; use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; -use function Kadet\Functional\compile; +use function Kadet\Functional\Internal\callback; class AllOfPredicate extends AbstractPredicate implements Compilable { @@ -45,9 +45,13 @@ class AllOfPredicate extends AbstractPredicate implements Compilable return true; } + /** + * Each predicate gets only the arguments it takes: the first one alone when it is internal or declares one + * parameter, all of them otherwise. So a key passed by a collection function doesn't reach `is_numeric(...)`. + */ public function compile(): \Closure { - $predicates = array_map(compile(...), $this->predicates); + $predicates = array_map(static fn (Predicate $predicate) => callback($predicate), $this->predicates); return static function (mixed ...$args) use ($predicates): bool { foreach ($predicates as $predicate) { diff --git a/src/Predicate/AnyOfPredicate.php b/src/Predicate/AnyOfPredicate.php index 1ec5e21..a625286 100644 --- a/src/Predicate/AnyOfPredicate.php +++ b/src/Predicate/AnyOfPredicate.php @@ -13,7 +13,7 @@ namespace Kadet\Functional\Predicate; use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; -use function Kadet\Functional\compile; +use function Kadet\Functional\Internal\callback; class AnyOfPredicate extends AbstractPredicate implements Compilable { @@ -46,9 +46,13 @@ class AnyOfPredicate extends AbstractPredicate implements Compilable return false; } + /** + * Each predicate gets only the arguments it takes: the first one alone when it is internal or declares one + * parameter, all of them otherwise. So a key passed by a collection function doesn't reach `is_numeric(...)`. + */ public function compile(): \Closure { - $predicates = array_map(compile(...), $this->predicates); + $predicates = array_map(static fn (Predicate $predicate) => callback($predicate), $this->predicates); return static function (mixed ...$args) use ($predicates): bool { foreach ($predicates as $predicate) { diff --git a/src/Utils/FunctionPipeline.php b/src/Utils/FunctionPipeline.php index 34e3780..d62f6e0 100644 --- a/src/Utils/FunctionPipeline.php +++ b/src/Utils/FunctionPipeline.php @@ -30,6 +30,16 @@ class FunctionPipeline implements Compilable $this->functions = $functions; } + /** + * The function called first, with all arguments of the pipeline, or null when the pipeline is empty. + * + * @internal + */ + public function first(): ?callable + { + return $this->functions[0] ?? null; + } + public function append(callable $callable) { return new static(...array_merge($this->functions, [ $callable ])); diff --git a/tests/Collections/CallbackTest.php b/tests/CallbackTest.php similarity index 59% rename from tests/Collections/CallbackTest.php rename to tests/CallbackTest.php index 01e6575..5c9b26a 100644 --- a/tests/Collections/CallbackTest.php +++ b/tests/CallbackTest.php @@ -8,7 +8,7 @@ */ use Kadet\Functional as f; -use function Kadet\Functional\Collections\Internal\callback; +use function Kadet\Functional\Internal\callback; use const Kadet\Functional\_; class CallbackTest extends \PHPUnit\Framework\TestCase @@ -23,9 +23,37 @@ class CallbackTest extends \PHPUnit\Framework\TestCase $this->assertSame('A', callback('strtoupper')('a', 'key')); } - public function testSingleParamClosureGetsValueOnly() + public function testSingleParamClosureIsNotWrapped() { - $this->assertSame([1], callback(function ($v) { return func_get_args(); })(1, 'k')); + // PHP drops the key on its own: a userland closure ignores arguments past its parameters. + $closure = fn ($v) => $v; + + $this->assertSame($closure, callback($closure)); + } + + public function testDecoratedSingleParamClosureGetsValueOnly() + { + $this->assertSame([1], callback(f\memoize(function ($v) { return func_get_args(); }))(1, 'k')); + } + + public function testPipelineStartingWithInternalFunctionGetsValueOnly() + { + $this->assertSame('a', callback(f\pipe(trim(...)))(' a ', 'xyz')); + } + + public function testPipelineStartingWithTwoParamClosureGetsKey() + { + $this->assertSame('k=v', callback(f\pipe(fn ($v, $k) => "$k=$v"))('v', 'k')); + } + + public function testCarryAndValueOnlyForTwoParamTarget() + { + $this->assertSame([1, 2], callback(f\memoize(function ($c, $v) { return func_get_args(); }), 2)(1, 2, 'k')); + } + + public function testCarryValueAndKeyForThreeParamTarget() + { + $this->assertSame('1+2@k', callback(fn ($c, $v, $k) => "$c+$v@$k", 2)(1, 2, 'k')); } public function testTwoParamClosureGetsKey() diff --git a/tests/Collections/LibraryCallablesTest.php b/tests/Collections/LibraryCallablesTest.php index 72d1c2f..11ad3c7 100644 --- a/tests/Collections/LibraryCallablesTest.php +++ b/tests/Collections/LibraryCallablesTest.php @@ -10,6 +10,7 @@ use Kadet\Functional as f; use Kadet\Functional\Collections as c; use Kadet\Functional\Predicates as p; +use const Kadet\Functional\_; /** * The library's own callables (predicates, pipes, decorators) used as collection callbacks. @@ -37,4 +38,86 @@ class LibraryCallablesTest extends \PHPUnit\Framework\TestCase { $this->assertSame([1 => 2], c\reject(p\gt(1)->not())([1, 2])); } + + // pipelines get only the value when their first function takes only the value + + public function testMapWithPipelineOfInternalFunctions() + { + $slug = f\pipe(trim(...), strtolower(...), f\partial(str_replace(...), ' ', '-', _)); + + $this->assertSame(['n' => 'main-n'], c\map($slug)(['n' => 'Main n'])); + } + + public function testMapWithPipelineOfInternalFunctionWithOptionalParameter() + { + $this->assertSame([1.0, 3.0, 3.0], c\map(f\pipe(round(...)))([1.25, 2.5, 3.14159])); + } + + public function testMapWithPipelineOfMemoizedOneParameterClosure() + { + $calls = 0; + $pipeline = f\pipe(f\memoize(function ($x) use (&$calls) { + $calls++; + + return $x * 2; + }), fn ($x) => $x + 1); + + $this->assertSame(['a' => 3, 'b' => 3], c\map($pipeline)(['a' => 1, 'b' => 1])); + $this->assertSame(1, $calls); + } + + public function testMapWithPipelineStartingWithTwoParameterClosureGetsKey() + { + $this->assertSame(['a' => 'A=X'], c\map(f\pipe(fn ($v, $k) => "$k=$v", strtoupper(...)))(['a' => 'x'])); + } + + public function testFilterWithPredicateOfPipeline() + { + $a = (object) ['x' => 1]; + $b = (object) ['x' => 2]; + + $this->assertSame([1 => $b], c\filter(p\property('x', p\gt(1)))([$a, $b])); + } + + // predicate combinators forward to each predicate only what it takes + + public function testFilterWithAllOfInternalFunctionAndClosure() + { + $this->assertSame([0 => '1'], c\filter(f\all(is_numeric(...), fn ($x) => $x > 0))(['1', 'x', '-1'])); + } + + public function testFilterWithAnyOfInternalFunctionAndClosure() + { + $this->assertSame([0 => '1', 2 => 'y'], c\filter(f\any(is_numeric(...), fn ($x) => $x === 'y'))(['1', 'x', 'y'])); + } + + public function testFilterWithAllOfPassesKeyToPredicateTakingIt() + { + $this->assertSame(['b' => 2], c\filter(f\all(is_int(...), fn ($v, $k) => $k === 'b'))(['a' => 1, 'b' => 2])); + } + + public function testFilterWithNegatedAllOfInternalFunctions() + { + $this->assertSame([1 => 'x'], c\filter(f\not(f\all(is_numeric(...), is_string(...))))(['1', 'x'])); + } + + // decorators + + public function testMemoizedClosureIsCachedByValueOnly() + { + $calls = 0; + $memoized = f\memoize(function ($x) use (&$calls) { + $calls++; + + return $x * 2; + }); + + $this->assertSame([2, 2, 2, 2], c\map($memoized)([1, 1, 1, 1])); + $this->assertSame(1, $calls); + } + + public function testMemoizedInternalFunctionGetsValueOnly() + { + $this->assertSame([1.0, 3.0, 3.0], c\map(f\memoize(round(...)))([1.25, 2.5, 3.14159])); + } }