Compare commits

..

35 Commits

Author SHA1 Message Date
Kacper Donat
6cb6a22f18 release 1.1.0 2026-10-28 21:33:44 +01:00
Kacper Donat
a61db75c7e add chunk to split values into arrays of a size 2026-10-28 21:32:47 +01:00
Kacper Donat
ab3034e2be release 1.0.1 2026-10-28 10:05:43 +01:00
Kacper Donat
4ad971b431 document enum ordering of native sorts 2026-10-28 10:05:33 +01:00
Kacper Donat
b2550cf80b pass by-value only to native filter paths 2026-10-28 10:05:33 +01:00
Kacper Donat
d013a84f90 avoid by-reference warnings in filter 2026-10-28 09:56:11 +01:00
Kacper Donat
71e1b2cbdc keep currying when compiling curried closures 2026-10-28 09:56:11 +01:00
Kacper Donat
a4a51e32a1 sort natively in sortBy and sort 2026-10-28 09:56:11 +01:00
Kacper Donat
dc1b684978 release 1.0.0 2026-10-27 23:13:07 +01:00
Kacper Donat
e5e28fd8a5 add license and export-ignore development files 2026-10-27 23:12:18 +01:00
Kacper Donat
c27e326d59 document callback adaptation, errors and min/max ties 2026-10-27 23:12:05 +01:00
Kacper Donat
db3aab644e specialize compiled partials with one placeholder 2026-10-27 23:10:08 +01:00
Kacper Donat
04dfb21916 compile one-argument pipelines and predicates without spreading 2026-10-27 23:09:23 +01:00
Kacper Donat
f3f05fd00c add native fast paths for eager transforms
Arrays and php-ds collections are transformed directly instead of through the lazy generators. merge() into an array now rejects keys that can't be array keys with a clear error, like toArray().
2026-10-27 23:08:34 +01:00
Kacper Donat
4fe9da0167 pass only carry and value to two-parameter reducers 2026-10-27 23:04:39 +01:00
Kacper Donat
d4f1130077 adapt callbacks by target arity 2026-10-27 23:04:09 +01:00
Kacper Donat
0c91360368 compile predicates and pipelines 2026-10-27 23:02:34 +01:00
Kacper Donat
eac27c9e63 move applyWithArity to internal namespace 2026-10-27 23:00:38 +01:00
Kacper Donat
a09e6840d4 fix closure for static method array callables 2026-10-27 23:00:21 +01:00
Kacper Donat
28408baa64 clean up static analysis baseline 2026-10-27 22:59:30 +01:00
Kacper Donat
978a49091f fix negated predicate decorated callable 2026-10-27 22:59:06 +01:00
Kacper Donat
61f934ffd7 type accessors for static analysis 2026-10-27 22:44:36 +01:00
Kacper Donat
b20ef22cfd document collections and add type tests 2026-10-27 22:38:37 +01:00
Kacper Donat
ed427dd6cd fix lazy tests 2026-10-27 22:26:35 +01:00
Kacper Donat
364b97deaa add lazy collection transforms 2026-10-27 22:24:58 +01:00
Kacper Donat
b836bc5e8f move query helpers to internal namespace 2026-10-27 22:22:12 +01:00
Kacper Donat
483375240b add collection queries 2026-10-27 22:21:50 +01:00
Kacper Donat
1cddbe796a add collectors and grouping 2026-10-27 22:19:33 +01:00
Kacper Donat
04c40087c2 hoist callback compilation out of transform closures 2026-10-27 22:18:48 +01:00
Kacper Donat
b48f28e9dc add eager collection transforms 2026-10-27 22:15:52 +01:00
Kacper Donat
159e9a56ab pass value only to decorated internal callbacks 2026-10-27 22:11:15 +01:00
Kacper Donat
66705f3e31 share apply dispatch with compiled fixed functions 2026-10-27 22:11:15 +01:00
Kacper Donat
bdb49b0d78 drop extra arguments for compiled internal functions 2026-10-27 22:08:37 +01:00
Kacper Donat
1e94adc872 add compilable decorators 2026-10-27 22:07:36 +01:00
Kacper Donat
8e824ef93c upgrade to php 8.2 and phpunit 11 2026-10-27 22:04:21 +01:00
55 changed files with 6748 additions and 1100 deletions

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
# Development files, left out of the package archives Composer downloads.
/.gitattributes export-ignore
/.gitignore export-ignore
/.superpowers export-ignore
/composer.lock export-ignore
/tests export-ignore
/phpunit.xml export-ignore
/phpunit.no-ds.xml export-ignore
/phpstan.neon export-ignore
/phpstan.types.neon export-ignore
/phpstan-baseline.neon export-ignore

1
.gitignore vendored
View File

@ -6,3 +6,4 @@
/node_modules/
test.*.php
.phpunit.cache

40
CHANGELOG.md Normal file
View File

@ -0,0 +1,40 @@
# Changelog
## 1.1.0
### Added
- `chunk($size, $preserveKeys = false)` splits the values into arrays of `$size`, like `array_chunk()`. The lazy one yields each chunk as soon as it is full, so a streamed source is never held whole; the eager one gives a list array for an array and a `Ds\Seq` for anything else. A size below 1 throws `InvalidArgumentException`.
## 1.0.1
### Changed
- `sort()` without a comparator and `sortBy()` sort natively, comparing like `<=>`. Enums used directly as the sort key (or as the values for `sort()`), which `<=>` can't order, are now grouped by case, in an unspecified order, and placed after the other values (before them in descending order). Enums nested inside arrays are unaffected.
### Fixed
- `sortBy()` and `sort()` are several times faster: they use PHP's native sort instead of `usort()` with a userland comparator.
- `compile()` of a closure of a curried function (`$curried(...)`), alone or in a pipeline, keeps currying instead of throwing `ArgumentCountError`.
- `filter()` on arrays with a callback taking its value by reference (`fn (&$value) => ...`) no longer emits "must be passed by reference" warnings.
## 1.0.0
### Breaking
- PHP 8.2 or newer is required.
- Contributors need PHPUnit 11 to run the test suite.
### Added
- `compile()` and `Compilable`: partials, curried and fixed functions, pipes and predicates flatten into plain closures once, so hot loops don't go through their `__invoke()`. Compiled internal functions drop extra arguments.
- Collections in `Kadet\Functional\Collections`: transforms (map, filter and friends), queries, and collectors with grouping. The transforms also come lazy, returning generators, in `Kadet\Functional\Collections\Lazy`; queries and collectors accept lazy input and end a lazy chain.
- Collection callbacks get `($value, $key)`, except internal functions and callables declaring one parameter, which get only the value. Wrappers (`partial()`, `memoize()`, predicates, `pipe()`) are seen through to the function they call.
- `php-ds` is suggested for the collections. `DsUnavailableException` is thrown when it is needed but not installed.
- `EmptyCollectionException` is thrown by queries that need an element when the collection has none (or none matching).
- Docblock types and generics, so static analysis understands collection contents.
### Fixed
- `not()` and `Predicate::not()` predicates return the negated predicate from `getDecorated()` instead of null.
- `closure()` and `ref()` accept static method callables like `[Foo::class, 'method']`.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2026 Kacper Donat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

483
README.md Normal file
View File

@ -0,0 +1,483 @@
# kadet/functional
Small functional building blocks for PHP 8.2+: predicates, property and method accessors, partial application and
currying, and a set of collection functions made for the pipe operator. They work on arrays, generators and
[php-ds](https://github.com/php-ds/ext-ds) collections.
```php
use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Transforms as t;
$delayed = $departures
|> lazy\filter(p\property('delay', p\gt(60)))
|> c\sortBy(t\property('delay'), descending: true)
|> c\keyBy(t\property('id'))
|> c\map(fn (Departure $departure) => "{$departure->line} to {$departure->destination}");
```
- [Installation](#installation)
- [Predicates](#predicates)
- [Transforms](#transforms)
- [Partial application, currying and composition](#partial-application-currying-and-composition)
- [Compiling decorators](#compiling-decorators)
- [Collections](#collections)
- [Static analysis](#static-analysis)
- [Contributing](#contributing)
## Installation
```bash
composer require kadet/functional
```
The library has no required dependencies. For the collection functions, php-ds is recommended: either the
extension (`pie install php-ds/ext-ds:^2.0`) or the polyfill (`composer require php-ds/php-ds`). Without it, everything
that works on arrays and generators still does; see [Without php-ds](#without-php-ds).
The examples below use this class, and `$departures` is a list of them:
```php
final class Departure
{
public function __construct(
public readonly string $id,
public readonly string $line,
public readonly string $destination,
public readonly int $delay = 0, // in seconds
) {
}
}
$departures = [
new Departure('d1', '8', 'Stogi'),
new Departure('d2', '8', 'Stogi', delay: 120),
new Departure('d3', '12', 'Oliwa', delay: 30),
new Departure('d4', '12', 'Oliwa', delay: 300),
new Departure('d5', '6', 'Łostowice'),
];
```
## Predicates
A predicate is a callable object returning a bool. The functions in `Kadet\Functional\Predicates` build the common
ones, and predicates combine with `and()`, `or()` and `not()`.
```php
use Kadet\Functional as f;
use Kadet\Functional\Predicates as p;
$late = p\property('delay', p\gt(60));
$toOliwa = p\property('destination', p\same('Oliwa'));
$late($departures[3]); // true
$late->and($toOliwa)($departures[1]); // false: late, but going to Stogi
$late->or($toOliwa)->not()($departures[0]); // true
f\not($late)($departures[0]); // true
```
Comparisons: `equals` (`==`), `same` (`===`), `gt`, `ge`, `lt`, `le`. Accessors: `property` (uses a `getX()`
getter when there is one), `field`, `method`. Plus `instance($class)`. `f\all(...)` and `f\any(...)` combine many
predicates at once, and `f\always()` / `f\never()` are constants.
## Transforms
`Kadet\Functional\Transforms` builds accessor functions, handy as selectors:
```php
use Kadet\Functional\Transforms as t;
t\property('line')($departures[0]); // '8', via getLine() when the object has one
t\field('line')($departures[0]); // '8', always the field itself
t\method('format', 'H:i')(new DateTimeImmutable('12:30')); // '12:30'
```
## Partial application, currying and composition
```php
use Kadet\Functional as f;
use const Kadet\Functional\_;
// Fix some arguments; `_` leaves a hole to fill later.
$replaceA = f\partial(str_replace(...), 'a', _, 'banana');
$replaceA('o'); // 'bonono'
// A curried function waits until it has all required arguments.
$between = f\curry(fn (int $min, int $max, int $value) => $value >= $min && $value <= $max);
$isHour = $between(0, 23);
$isHour(12); // true
$between(0)(59)(60); // false
// Left-to-right composition.
$slug = f\pipe(trim(...), strtolower(...), f\partial(str_replace(...), ' ', '-', _));
$slug(' Main Station '); // 'main-station'
// Anonymous recursion: the function receives itself as the first argument.
$factorial = f\fix(fn ($self, int $n) => $n < 2 ? 1 : $n * $self($n - 1));
$factorial(5); // 120
// Remember results by arguments.
$slow = f\memoize(fn (string $stop) => strtoupper($stop)); // imagine an expensive lookup
$slow('stogi'); // computed
$slow('stogi'); // remembered
```
## Compiling decorators
`partial()`, `curry()`, `fix()`, `pipe()` and the predicates return objects. Calling one goes through its
`__invoke()`, which inspects the arguments every time. That is fine for a single call, but in a loop over thousands
of elements it adds up. `f\compile()` flattens such a callable into a plain `\Closure` once, so the loop pays only
for the functions it calls:
```php
use Kadet\Functional as f;
use const Kadet\Functional\_;
$pad = f\compile(f\partial(str_pad(...), _, 3, '0', STR_PAD_LEFT));
$pad('7'); // '007'
```
Objects implementing `Kadet\Functional\Compilable` are compiled with their own `compile()`, also when given as a
closure of their `__invoke()`; any other callable is converted to a closure. Compiling is recursive: a predicate
like `p\property('delay', p\gt(60))` becomes a closure calling the accessor and the comparison directly. Implement
`Compilable` on your own decorators to get the same benefit. `memoize()` is not compiled, its cache is the point.
A compiled callable behaves like the object, with two differences. When the call ends in an internal function,
arguments it can't take are dropped instead of throwing. And a compiled `curry()` calls its function with the
arguments it gets, without currying further.
You rarely need to call `compile()` yourself: the collection functions compile every callback once per step,
predicates and pipes included.
## Collections
The collection functions take their configuration and return a function that takes the data, so a pipeline is a
chain of `|>`:
```php
use Kadet\Functional\Collections as c; // eager: returns collections
use Kadet\Functional\Collections\Lazy as lazy; // lazy: returns generators
$lines = $departures
|> c\filter(fn (Departure $departure) => $departure->delay > 0)
|> c\map(t\property('line'))
|> c\unique()
|> c\values();
// ['8', '12']
```
The namespace says which flavour you get. `c\` is eager and gives a finished collection after every step. `lazy\`
holds the streaming transforms, which give a generator and do no work until something reads it. Collectors and
queries end a chain, turning it into a collection or a single value, so there is nothing to delay and they exist
only in `c\`. They take any iterable, generators included, and are how a lazy chain ends. Sorting needs the whole
input before it can give the first element, so it is eager only as well.
### Pipes, and calling without them
Every function returns a closure, and there is no "data first" form. One shape for everything means every function
composes the same way, and it avoids an ambiguity: a callable array like `[$repository, 'find']` is also an
iterable, so `c\first($something)` could not tell data from a predicate.
The pipe operator needs PHP 8.5. On earlier versions, or for a single step, call the closure directly or compose
steps with `f\pipe()`:
```php
c\first()($departures); // the first departure
$firstLate = f\pipe(c\filter(p\property('delay', p\gt(60))), c\first());
$firstLate($departures); // d2
```
Where a php-ds method reads better for a one-off (`$seq->first()`), use it.
### Callbacks get the value, then the key
Every callback, predicate and selector is called as `$fn($value, $key)`, for every kind of input, `Ds\Map`
included:
```php
$codes = new Ds\Map(['STG' => 'Stogi', 'OLW' => 'Oliwa']);
$codes |> c\map(fn (string $name, string $code) => "$name ($code)");
// Ds\Map ['STG' => 'Stogi (STG)', 'OLW' => 'Oliwa (OLW)']
```
Value first, because most callbacks only need the value. It is also the order of `array_filter()` with
`ARRAY_FILTER_USE_BOTH`. `Ds\Map::map()` itself passes `($key, $value)`: when you switch between the two, the
library's order is the one that stays the same across arrays, generators and all php-ds types.
### Builtins get only the value
Passing the key along is a trap for functions with optional parameters. `round()` takes a precision as its second
argument, so a naive `($value, $key)` call would round the first element to 0 places, the second to 1 and the
third to 2. To prevent that, two kinds of callables are called with the value only:
- **PHP's internal functions**, written as first-class callables: `trim(...)`, `round(...)`, `strtoupper(...)`.
- **Any callable declaring exactly one parameter**, like `fn ($value) => ...` or a one-parameter method.
The library's own wrappers are seen through: what counts is the function a callable ultimately calls. For
`partial()`, `curry()`, `memoize()` and `f\predicate()` that is the wrapped function, for `f\not()` the
negated predicate, and for `f\pipe()` its first function, the one that gets the arguments. So
`f\partial(str_pad(...), _, 3, '0')`, `f\memoize(fn ($stop) => ...)` (cached by the value alone) and
`f\pipe(trim(...), strtolower(...))` all get only the value. `f\all()` and `f\any()` decide for each of their
predicates: in `f\all(is_numeric(...), fn ($value, $key) => ...)` the first gets the value, the second the key too.
```php
[1.25, 2.5, 3.14159] |> c\map(round(...)); // [1.0, 3.0, 3.0], not [1.0, 2.5, 3.14]
[' a ', ' b'] |> c\map(trim(...)); // ['a', 'b']; the key never becomes trim's character list
['n' => ' Main St '] |> c\map(f\pipe(trim(...), strtolower(...))); // ['n' => 'main st']
```
Any other callable gets the key, **even into an optional parameter**. A method like
`format(Departure $departure, string $style = 'short')` would receive the key as `$style`. Wrap it in a
one-parameter closure: `c\map(fn ($departure) => $formatter->format($departure))`.
Builtins are written as first-class callables (`trim(...)`) throughout. String callables (`'trim'`) work too, but
`trim(...)` fails right where it is written if the function doesn't exist, is understood by IDEs and static
analysis, and can't be mistaken for data.
### What you get back
Eager transforms return the same kind of container they were given, so a `Seq` stays a `Seq` and an array stays an
array. The details:
| Input | Keyed transforms¹ | List transforms² |
|---|---|---|
| array | array, keys preserved | list array |
| `Ds\Map` | `Ds\Map`, keys preserved | `Ds\Seq` |
| `Ds\Seq` | `Ds\Seq`, reindexed | `Ds\Seq` |
| `Ds\Set` | `Ds\Set` | `Ds\Set` (but `keys()` gives a `Ds\Seq`) |
| generator, any other iterable | `Ds\Seq` (a list array without php-ds) | `Ds\Seq` (a list array without php-ds) |
¹ `map`, `filter`, `reject`, `unique`, `uniqueBy`, `sort`, `sortBy`, `slice`, `take`, `skip`, `merge`, `each`,
`tap`. ² `flatMap`, `values`, `keys`, `prepend`, `append`, `concat`: these produce a list, and a `Ds\Map` can't hold
a list, so it becomes a `Ds\Seq`. `chunk` also produces a list, of arrays, and gives a `Ds\Seq` for a `Ds\Set` too.
A few consequences worth knowing:
- **A generator given to an eager transform comes back as a `Ds\Seq`** (or a list array without php-ds). The
callbacks still see the generator's keys, but the result is a list. A generator can be read only once, so
returning a half-read generator, or a generator at all, from an eager function would be a trap.
- **`each()`/`tap()` return their input** unchanged for arrays and php-ds collections, which makes them safe to put
in the middle of a chain for logging. A generator is consumed by them, so they return its values as a
`Ds\Seq`, not the spent generator.
- `filter()` on an array keeps the keys, like `array_filter()`. Add `c\values()` when you need a list.
- `map()` on a `Ds\Set` follows `Ds\Set::map()`, so results that are equal collapse into one.
- `merge()` merges arrays and `Ds\Map` by key (integer keys of arrays are kept, unlike `array_merge()`); for other
inputs it appends.
- `sort()`/`sortBy()` are stable and never modify their input, unlike `Ds\Seq::sort()`.
- `sort()` without a comparator and `sortBy()` sort natively, comparing like `<=>`. The one difference is enums
used directly as the sort key (or as the values for `sort()`), which `<=>` can't order: they are grouped by case,
in an unspecified order (PHP compares object addresses, not declaration order), and placed after the other values
(before them in descending order). Enums nested inside arrays compare as `<=>` does.
### Finding things: `first`, `find` and `required`
`first()` and `last()` return `null` when the collection is empty or nothing matches. A missing value is usually
an expected outcome (no departure from this stop right now), so it should not need a `try`. When the value must be
there, say so with `required: true`, and get an exception that names the function and what went wrong:
```php
$departures |> c\first(p\property('line', p\same('12'))); // d3
$departures |> c\first(p\property('line', p\same('2'))); // null
$departures |> c\first(p\property('line', p\same('2')), required: true);
// EmptyCollectionException: first(): no element matched
[] |> c\last(required: true);
// EmptyCollectionException: last(): collection is empty
```
`required` also makes a found `null` distinguishable: `[null] |> c\first(required: true)` returns `null` without
throwing, because something was found.
`contains($value)` and `any($predicate)` are separate functions on purpose. A string like `'date'` is both a
value and a valid callable, so one function taking either could not know which you meant. `contains()` compares
strictly (`===`; a `Ds\Key` with `equals()`), `any()` calls the predicate.
### Aliases
Some functions have a second name, so the code can say what it means. Each is a one-line delegation and behaves
exactly like its target:
| Alias | Same as | Use it when |
|---|---|---|
| `find($predicate)` | `first($predicate)`, with the predicate required | you are searching, not taking the head |
| `some($predicate)` | `any($predicate)` | it reads better in a condition: `if ($stops \|> c\some(...))` |
| `tap($fn)` | `each($fn)` | the call is for a side effect in the middle of a chain, like logging |
### Transforms
`map`, `filter` (keeps truthy values without a callback), `reject`, `flatMap`, `values`, `keys`, `unique`,
`uniqueBy`, `sort` (comparator or `<=>`), `sortBy` (selector, optionally `descending: true`), `slice`, `take`,
`skip`, `chunk` (size, optionally `preserveKeys: true`, like `array_chunk()`), `prepend`, `append`, `concat`, `merge`,
`each`, `tap`.
```php
$delays = $departures
|> c\reject(fn (Departure $departure) => $departure->delay === 0)
|> c\sortBy(t\property('delay'))
|> c\map(fn (Departure $departure) => sprintf('%s: +%d min', $departure->id, $departure->delay / 60))
|> c\values();
// ['d3: +0 min', 'd2: +2 min', 'd4: +5 min']
```
### Collectors
`keyBy`, `groupBy` (a `Ds\Map` of `Ds\Seq`, in encounter order), `toSeq`, `toMap`, `toSet` and `toArray`. All but
`toArray` produce php-ds collections.
```php
$byLine = $departures |> c\groupBy(t\property('line'));
// Ds\Map ['8' => Seq [d1, d2], '12' => Seq [d3, d4], '6' => Seq [d5]]
$byId = $departures |> c\keyBy(t\property('id'));
$byId->get('d4')->destination; // 'Oliwa'
```
`toArray()` throws an `InvalidArgumentException` when a key can't be an array key, like an object key of a
`Ds\Map`, instead of PHP's "Illegal offset type". So does `merge()` when it merges such keys into an array.
### Queries
`first`, `last`, `find`, `contains`, `any`, `some`, `every`, `count`, `isEmpty`, `min`, `max`, `reduce`, `sum` and
`join` turn a collection into a single value. `first`, `find`, `any`, `some`, `every`, `contains` and `isEmpty`
stop reading as soon as the answer is known, which matters for generators. The others read everything, with two
shortcuts: `last` reads arrays and php-ds collections from the end, and `count` asks countable containers for their
size.
```php
$departures |> c\every(fn (Departure $departure) => $departure->delay < 600); // true
$departures |> c\sum(t\property('delay')); // 450
$departures |> c\max(t\property('delay')); // 300
$departures |> c\map(t\property('line')) |> c\unique() |> c\join(', '); // '8, 12, 6'
$departures |> c\reduce(fn (int $carry, Departure $departure) => $carry + 1, 0); // 5
```
**`min()` and `max()` with a selector return the selected value, not the element.** Above, `c\max()` gives `300`,
the largest delay, not the departure that has it. To get the element, sort and take the first:
```php
$departures |> c\sortBy(t\property('delay'), descending: true) |> c\first(); // d4
```
On a tie, `min()` and `max()` return the first of the equal values: `[1, 1.0] |> c\max()` is `1`, and of two
objects equal under `<=>` you get the one that came first.
`reduce()` callbacks take the carry first: `fn ($carry, $value, $key)`. Like the value-only rule above, an internal
function or a callable declaring exactly two parameters gets only `($carry, $value)`, so
`[5 => 1, 9 => 2] |> c\reduce(max(...), 0)` is `2`, not the key `9`.
### Errors
Exceptions name the function and the problem. Two kinds are thrown:
- **Invalid arguments** raise PHP's own SPL exceptions: `InvalidArgumentException` (`c\take(-1)`, a key that can't be
an array key in `toArray()` or `merge()`) and `UnexpectedValueException` (a `flatMap()` callback that returns no
iterable).
- **Library conditions** raise subclasses of `Kadet\Functional\Exception\FunctionalException`:
`EmptyCollectionException` (a `required` query found nothing) and `DsUnavailableException` (a collector needs
php-ds, see below).
### Lazy pipelines
`lazy\map`, `filter`, `reject`, `flatMap`, `values`, `keys`, `unique`, `uniqueBy`, `slice`, `take`, `skip`,
`prepend`, `append`, `concat`, `each` and `tap` return a generator. Each element travels through the whole chain
before the next one is read, and nothing happens until the chain is read by a collector or a query:
```php
function readDepartures(string $path): Generator
{
foreach (new SplFileObject($path) as $line) {
if ($line !== '') {
yield json_decode($line, true);
}
}
}
$firstTram = readDepartures('departures.jsonl')
|> lazy\filter(fn (array $row) => $row['vehicle'] === 'tram')
|> lazy\map(fn (array $row) => new Departure($row['id'], $row['line'], $row['destination']))
|> c\first(); // reads the file only up to the first tram
```
Choose lazy when:
- **the input is large or streamed** (an import, a file, a database cursor): no step builds an intermediate
collection, so memory stays flat;
- **you stop early**: `first`, `find`, `any` or `take` on a big input read only as far as needed;
- **the input is infinite**, like a generator counting up.
Stay eager when the result is stored, returned from a function or read more than once. A generator can be read
only once, and a second `foreach` over it silently sees nothing (or throws). So don't let a lazy value leave the
function that builds it: end the chain with a collector (`c\toSeq()`, `c\keyBy()`, ...) or a query. For small
collections, eager is also easier to debug, since every step has a value you can look at.
### Without php-ds
php-ds is optional. Without it:
- every transform, lazy or eager, works on arrays and generators; eager transforms given a generator return a list
array instead of a `Ds\Seq`;
- `toArray()` and all queries work;
- `keyBy`, `groupBy`, `toSeq`, `toMap` and `toSet` throw `Kadet\Functional\Exception\DsUnavailableException`
("keyBy() needs php-ds: install ext-ds or require php-ds/php-ds"). They throw when the collector is built, not
when data arrives, so `$fn = c\keyBy(...)` fails at once, at the line that needs php-ds, even if no data ever
reaches it.
With php-ds you get `Ds\Map` keys of any type (objects included), collections that are objects (passed by handle,
not copied), and results whose type says whether they are a list (`Seq`), a mapping (`Map`) or a set (`Set`).
## Static analysis
Every function in `Collections` has `@template` generics, so PHPStan (level 8 in this repository) follows element
types through a chain, `|>` included:
```php
/** @param Ds\Seq<Departure> $departures */
function delayedLines(Ds\Seq $departures): ?string
{
return $departures
|> c\filter(fn (Departure $departure): bool => $departure->delay > 0) // Ds\Seq<Departure>
|> c\map(fn (Departure $departure): string => $departure->line) // Ds\Seq<string>
|> c\first(); // string|null
}
```
Eager transforms with a callback keep the container in the type as well (a `Ds\Map` in, a `Ds\Map` out; a
generator in, `Ds\Seq|list` out). A callable PHPStan knows nothing about never causes an error, it only makes
the result `mixed`. The limits of what can be inferred:
- **Element types come from the callback's parameter and return types.** `fn ($departure) => ...` without types
gives `mixed`.
- **Accessors and predicates are not generic.** `t\property()`, `t\field()`, `t\method()` and `t\getter()` are typed
`\Closure(object): mixed`, and the `p\` builders return `Predicate`. Chains using them analyse cleanly, but the
elements become `object` (`c\sortBy(t\property('delay'))`) or `mixed` (`c\map(t\property('line'))`,
`c\max(t\property('delay'))`). Where the type matters, write the closure:
`c\map(fn (Departure $departure): string => $departure->line)`.
- **`sum()` doesn't check that the values are numbers** when called without a selector.
- **Keys are inferred only when the callback declares a `$key` parameter.** Otherwise the keys of the result are
`mixed` (`int|string` for arrays).
- **Transforms without a callback** (`values`, `keys`, `unique`, `sort()`, `slice`, `take`, `skip`, `prepend`,
`append`, `concat`, `merge`) keep the element types but are typed as returning `iterable`: the container type is
lost. Follow them with a collector such as `c\toSeq()` if you need it.
- With the php-ds **polyfill**, PHPStan does not treat `Ds\Seq` as generic: `Ds\Seq<T>` shows as
`Ds\Seq&iterable<T>`, and the values of a `Ds\Map` read as `mixed`, so queries over a map (`c\first()`, `c\last()`)
give `mixed`.
## Contributing
```bash
composer test # both PHPUnit suites
composer phpstan # PHPStan level 8: src/ as PHP 8.2 (phpstan.neon), tests/types/ as PHP 8.5 (phpstan.types.neon)
```
- `composer test` runs the main suite and the no-php-ds suite (`phpunit.no-ds.xml`), which hides the polyfill to
check the paths without php-ds. **The no-ds suite is skipped when ext-ds is loaded**, because an extension can't
be hidden: it prints a notice and passes. Run it on a PHP without ext-ds to cover those paths.
- `tests/PipeSyntaxTest.php` is the only test that uses `|>`. It is loaded only on PHP 8.5 and newer (see
`phpunit.xml`), so the rest of the suite still runs on PHP 8.2. Keep `|>` out of other tests.
- `tests/types/` holds PHPStan type assertions (`assertType()`). They are analysed, never executed, with their own
config, because they use `|>`. Add one when you change a signature.
- `phpstan-baseline.neon` lists errors in code that predates PHPStan in this project. New code must not add to it.

View File

@ -1,8 +0,0 @@
#!/usr/bin/env php
<?php
require_once __DIR__.'/../vendor/autoload.php';
use Kadet\Functional as f;
use Kadet\Functional\Predicates as p;
eval(\Psy\sh());

View File

@ -10,11 +10,12 @@
}
],
"require": {
"php": ">=7.1"
"php": ">=8.2"
},
"require-dev": {
"phpunit/phpunit": "^7.0",
"psy/psysh": "@stable"
"phpunit/phpunit": "^11.5",
"php-ds/php-ds": "^2.0",
"phpstan/phpstan": "^2.1"
},
"autoload": {
"psr-4": {
@ -22,7 +23,27 @@
},
"files": [
"./src/functions.php",
"./src/Predicates/index.php"
"./src/Internal/functions.php",
"./src/Predicates/index.php",
"./src/Collections/Internal/containers.php",
"./src/Collections/Internal/queries.php",
"./src/Collections/Lazy/transforms.php",
"./src/Collections/transforms.php",
"./src/Collections/collectors.php",
"./src/Collections/queries.php"
]
},
"suggest": {
"php-ds/php-ds": "^2.0 (or ext-ds) for Seq/Map/Set results"
},
"scripts": {
"test": [
"phpunit",
"phpunit -c phpunit.no-ds.xml"
],
"phpstan": [
"phpstan analyse",
"phpstan analyse -c phpstan.types.neon"
]
}
}

1870
composer.lock generated

File diff suppressed because it is too large Load Diff

679
phpstan-baseline.neon Normal file
View File

@ -0,0 +1,679 @@
parameters:
ignoreErrors:
-
message: '#^Method Kadet\\Functional\\Decorator\:\:getDecorated\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Decorator.php
-
message: '#^Method Kadet\\Functional\\Predicate\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\:\:and\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\:\:or\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\AbstractPredicate\:\:and\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/AbstractPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\AbstractPredicate\:\:or\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/AbstractPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\AllOfPredicate\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/AllOfPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\AllOfPredicate\:\:and\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/AllOfPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\AnyOfPredicate\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/AnyOfPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\AnyOfPredicate\:\:or\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/AnyOfPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ClosurePredicate\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/ClosurePredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ClosurePredicate\:\:getDecorated\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Predicate/ClosurePredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ConstantPredicate\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/ConstantPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ConstantPredicate\:\:always\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Predicate/ConstantPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ConstantPredicate\:\:and\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/ConstantPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ConstantPredicate\:\:never\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Predicate/ConstantPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\ConstantPredicate\:\:or\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/ConstantPredicate.php
-
message: '#^Unsafe usage of new static\(\)\.$#'
identifier: new.static
count: 2
path: src/Predicate/ConstantPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\NegatedPredicate\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicate/NegatedPredicate.php
-
message: '#^Method Kadet\\Functional\\Predicate\\NegatedPredicate\:\:getDecorated\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Predicate/NegatedPredicate.php
-
message: '#^Function Kadet\\Functional\\Predicates\\equals\(\) has parameter \$expected with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/comparisons.php
-
message: '#^Function Kadet\\Functional\\Predicates\\ge\(\) has parameter \$expected with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/comparisons.php
-
message: '#^Function Kadet\\Functional\\Predicates\\gt\(\) has parameter \$expected with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/comparisons.php
-
message: '#^Function Kadet\\Functional\\Predicates\\le\(\) has parameter \$expected with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/comparisons.php
-
message: '#^Function Kadet\\Functional\\Predicates\\lt\(\) has parameter \$expected with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/comparisons.php
-
message: '#^Function Kadet\\Functional\\Predicates\\same\(\) has parameter \$expected with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/comparisons.php
-
message: '#^Function Kadet\\Functional\\Predicates\\field\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/objects.php
-
message: '#^Function Kadet\\Functional\\Predicates\\method\(\) has parameter \$args with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Predicates/objects.php
-
message: '#^Function Kadet\\Functional\\Predicates\\method\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/objects.php
-
message: '#^Function Kadet\\Functional\\Predicates\\property\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Predicates/objects.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionAppliedParameter\:\:__construct\(\) has parameter \$function with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Reflection/ReflectionAppliedParameter.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionAppliedParameter\:\:__construct\(\) has parameter \$value with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Reflection/ReflectionAppliedParameter.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionAppliedParameter\:\:getValue\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Reflection/ReflectionAppliedParameter.php
-
message: '#^Property Kadet\\Functional\\Reflection\\ReflectionAppliedParameter\:\:\$value has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Reflection/ReflectionAppliedParameter.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionDecoratedFunction\:\:getClosureScopeClass\(\) return type with generic class ReflectionClass does not specify its types\: T$#'
identifier: missingType.generics
count: 1
path: src/Reflection/ReflectionDecoratedFunction.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionDecoratedFunction\:\:getStaticVariables\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Reflection/ReflectionDecoratedFunction.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionDecoratedFunction\:\:hasReturnType\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Reflection/ReflectionDecoratedFunction.php
-
message: '#^Property Kadet\\Functional\\Reflection\\ReflectionDecoratedFunction\:\:\$decorated has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Reflection/ReflectionDecoratedFunction.php
-
message: '#^Property Kadet\\Functional\\Reflection\\ReflectionDecoratedFunction\:\:\$reflection has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Reflection/ReflectionDecoratedFunction.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionPartiallyAppliedFunction\:\:getAppliedParameters\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Reflection/ReflectionPartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionPartiallyAppliedFunction\:\:getNumberOfAppliedParameters\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Reflection/ReflectionPartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Reflection\\ReflectionPartiallyAppliedFunction\:\:isCurried\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Reflection/ReflectionPartiallyAppliedFunction.php
-
message: '#^Property Kadet\\Functional\\Reflection\\ReflectionPartiallyAppliedFunction\:\:\$applied has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Reflection/ReflectionPartiallyAppliedFunction.php
-
message: '#^Function Kadet\\Functional\\Transforms\\field\(\) has parameter \$field with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\getter\(\) has parameter \$property with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\method\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\method\(\) has parameter \$method with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Function Kadet\\Functional\\Transforms\\property\(\) has parameter \$property with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Transforms/objects.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:__construct\(\) has parameter \$args with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:apply\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:apply\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:call\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:call\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:uncurry\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\CurriedFunction\:\:withThreshold\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Property Kadet\\Functional\\Utils\\CurriedFunction\:\:\$threshold has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Utils/CurriedFunction.php
-
message: '#^Unsafe usage of new static\(\)\.$#'
identifier: new.static
count: 2
path: src/Utils/CurriedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\FixedFunction\:\:__invoke\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/FixedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\FixedFunction\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/FixedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\FixedFunction\:\:getDecorated\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/FixedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\FunctionPipeline\:\:__invoke\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/FunctionPipeline.php
-
message: '#^Method Kadet\\Functional\\Utils\\FunctionPipeline\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/FunctionPipeline.php
-
message: '#^Method Kadet\\Functional\\Utils\\FunctionPipeline\:\:append\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/FunctionPipeline.php
-
message: '#^Method Kadet\\Functional\\Utils\\FunctionPipeline\:\:prepend\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/FunctionPipeline.php
-
message: '#^Unsafe usage of new static\(\)\.$#'
identifier: new.static
count: 2
path: src/Utils/FunctionPipeline.php
-
message: '#^Method Kadet\\Functional\\Utils\\MemoizedFunction\:\:__invoke\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/MemoizedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\MemoizedFunction\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/MemoizedFunction.php
-
message: '#^Property Kadet\\Functional\\Utils\\MemoizedFunction\:\:\$decorated has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Utils/MemoizedFunction.php
-
message: '#^Property Kadet\\Functional\\Utils\\MemoizedFunction\:\:\$memoized has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Utils/MemoizedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:__construct\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:__invoke\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:__invoke\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:fix\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:fix\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:getArguments\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:getDecorated\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:prepareArguments\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Method Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:prepareArguments\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Property Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:\$arguments has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Property Kadet\\Functional\\Utils\\PartiallyAppliedFunction\:\:\$callable has no type specified\.$#'
identifier: missingType.property
count: 1
path: src/Utils/PartiallyAppliedFunction.php
-
message: '#^Class Kadet\\Functional\\Utils\\UnpackedArgs extends generic class ArrayObject but does not specify its types\: TKey, TValue$#'
identifier: missingType.generics
count: 1
path: src/Utils/UnpackedArgs.php
-
message: '#^Function Kadet\\Functional\\apply\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\apply\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\curry\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\fix\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\fix\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\memoize\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\partial\(\) has parameter \$args with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\pipe\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\pipe\(\) has parameter \$functions with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/Utils/index.php
-
message: '#^Function Kadet\\Functional\\all\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\all\(\) has parameter \$predicates with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\always\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\any\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\any\(\) has parameter \$predicates with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\arify\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\arify\(\) has parameter \$callable with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\closure\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\closure\(\) has parameter \$callable with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\identity\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\identity\(\) has parameter \$x with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\never\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\not\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\not\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\predicate\(\) has parameter \$predicate with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\ref\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\ref\(\) has parameter \$callable with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\repeat\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\repeat\(\) has parameter \$times with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\symbol\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\symbol\(\) has parameter \$name with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\unpack\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/functions.php
-
message: '#^Function Kadet\\Functional\\unpack\(\) has parameter \$arguments with no type specified\.$#'
identifier: missingType.parameter
count: 1
path: src/functions.php

28
phpstan.neon Normal file
View File

@ -0,0 +1,28 @@
parameters:
level: 8
# The library's floor. The type tests use the 8.5 pipe operator, so they have their own config:
# phpstan.types.neon. `composer phpstan` runs both.
phpVersion: 80200
paths:
- src
ignoreErrors:
-
# Eager transforms are typed with a conditional closure type that maps the input container to the result
# container (see keyedLike()/listLike(), which implement it). PHPStan can't check a closure literal
# against such a type, so only that mismatch is ignored.
identifier: return.type
message: '#^Function Kadet\\Functional\\Collections\\\w+\(\) should return Closure<TIn of iterable>\(TIn\): \(TIn is array \?#'
path: src/Collections/transforms.php
-
# toArray() and merge() into an array keep PHP's own coercion of bool, float and null keys, as an array
# literal would.
identifier: offsetAccess.invalidOffset
message: '#^Possibly invalid array key type \(?bool#'
paths:
- src/Collections/collectors.php
- src/Collections/transforms.php
includes:
# Errors in the code that predates PHPStan in this project (untyped pre-1.0 functions and classes). New code
# must pass without entries here.
- phpstan-baseline.neon

7
phpstan.types.neon Normal file
View File

@ -0,0 +1,7 @@
# Type tests (tests/types): assertType() checks of what PHPStan infers for library users, including through the pipe
# operator, which needs PHP 8.5 to parse. The library itself is analysed against PHP 8.2 by phpstan.neon.
parameters:
level: 8
phpVersion: 80500
paths:
- tests/types

8
phpunit.no-ds.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0"?>
<phpunit bootstrap="tests/bootstrap-no-ds.php" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="no-ds">
<directory>tests/NoDs</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -1,11 +1,17 @@
<phpunit bootstrap="tests/bootstrap.php">
<?xml version="1.0"?>
<phpunit bootstrap="tests/bootstrap.php" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="all">
<directory>tests</directory>
<exclude>tests/NoDs</exclude>
<!-- Uses the pipe operator, so it only parses on PHP 8.5+: loaded by the <file> entry below. -->
<exclude>tests/PipeSyntaxTest.php</exclude>
<file phpVersion="8.5.0" phpVersionOperator=">=">tests/PipeSyntaxTest.php</file>
</testsuite>
</testsuites><filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src</directory>
</whitelist>
</filter>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View File

@ -0,0 +1,232 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional\Collections\Internal;
/**
* Whether php-ds (the extension or the polyfill) is available.
*
* @internal
*/
function dsAvailable(): bool
{
return class_exists(\Ds\Seq::class);
}
/**
* Builds a keyed result in the same kind of container as $input.
*
* Arrays and `Ds\Map` keep the keys of $entries, `Ds\Seq` and `Ds\Set` take only the values. Any other iterable
* gives a `Ds\Seq` of the values, or a list array when php-ds is unavailable.
*
* @internal
*
* @param iterable<mixed, mixed> $input
* @param iterable<mixed, mixed> $entries
*
* @return iterable<mixed, mixed>
*/
function keyedLike(iterable $input, iterable $entries): iterable
{
if (is_array($input)) {
$result = [];
foreach ($entries as $key => $value) {
$result[$key] = $value;
}
return $result;
}
if ($input instanceof \Ds\Map) {
$result = new \Ds\Map();
foreach ($entries as $key => $value) {
$result->put($key, $value);
}
return $result;
}
return listLike($input, $entries);
}
/**
* Builds a list result (sequential keys) for $input from the values of $values.
*
* Arrays give a list array, `Ds\Set` gives a `Ds\Set`, and anything else gives a `Ds\Seq`, or a list array when
* php-ds is unavailable.
*
* @internal
*
* @param iterable<mixed, mixed> $input
* @param iterable<mixed, mixed> $values
*
* @return iterable<int, mixed>
*/
function listLike(iterable $input, iterable $values): iterable
{
$list = is_array($values) ? array_values($values) : iterator_to_array($values, false);
if (is_array($input)) {
return $list;
}
if ($input instanceof \Ds\Set) {
return new \Ds\Set($list);
}
return seqOf($list);
}
/**
* What an eager transform iterates: the values of `Ds\Seq` and `Ds\Set` as an array (keyed by position, as
* iterating them gives), as iterating an array is faster; anything else as it is.
*
* @internal
*
* @param iterable<mixed, mixed> $input
*
* @return iterable<mixed, mixed>
*/
function iterated(iterable $input): iterable
{
return $input instanceof \Ds\Seq || $input instanceof \Ds\Set ? $input->toArray() : $input;
}
/**
* The values of an iterable as a list.
*
* @internal
*
* @param iterable<mixed, mixed> $iterable
*
* @return list<mixed>
*/
function valuesOf(iterable $iterable): array
{
return is_array($iterable) ? array_values($iterable) : iterator_to_array(iterated($iterable), false);
}
/**
* Wraps a list in a `Ds\Seq`, or returns it as it is when php-ds is unavailable.
*
* @internal
*
* @template TValue
*
* @param list<TValue> $list
*
* @return iterable<int, TValue>
*/
function seqOf(array $list): iterable
{
return dsAvailable() ? new \Ds\Seq($list) : $list;
}
/**
* Collects the entries of an iterable as [key, value] pairs, so that keys of any type survive.
*
* @internal
*
* @param iterable<mixed, mixed> $iterable
*
* @return list<array{mixed, mixed}>
*/
function entries(iterable $iterable): array
{
$entries = [];
foreach ($iterable as $key => $value) {
$entries[] = [$key, $value];
}
return $entries;
}
/**
* Builds a `Ds\Map` from entries given by position, in the order of the keys of $order (positions).
*
* @internal
*
* @param list<mixed> $keys
* @param array<int, mixed> $values
* @param array<int, mixed> $order
*
* @return \Ds\Map<mixed, mixed>
*/
function mapInOrder(array $keys, array $values, array $order): \Ds\Map
{
$result = new \Ds\Map();
$result->allocate(count($order));
foreach ($order as $position => $_) {
$result->put($keys[$position], $values[$position]);
}
return $result;
}
/**
* Yields [key, value] pairs back as key => value.
*
* @internal
*
* @param iterable<array{mixed, mixed}> $entries
*
* @return \Generator<mixed, mixed>
*/
function unpairs(iterable $entries): \Generator
{
foreach ($entries as [$key, $value]) {
yield $key => $value;
}
}
/**
* Tracks values already seen, compared strictly (objects by identity).
*
* The returned closure answers whether the given value was seen before and remembers it. Objects are kept
* referenced, so a temporary object's id can't be reused by a later one.
*
* @internal
*
* @return \Closure(mixed): bool
*/
function seen(): \Closure
{
$hashed = [];
$objects = [];
$other = [];
return static function (mixed $value) use (&$hashed, &$objects, &$other): bool {
if (is_object($value)) {
$id = spl_object_id($value);
if (isset($objects[$id])) {
return true;
}
$objects[$id] = $value;
return false;
}
if (is_int($value) || is_string($value) || is_bool($value) || $value === null) {
$hash = get_debug_type($value) . ':' . $value;
if (isset($hashed[$hash])) {
return true;
}
$hashed[$hash] = true;
return false;
}
if (in_array($value, $other, true)) {
return true;
}
$other[] = $value;
return false;
};
}

View File

@ -0,0 +1,99 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional\Collections\Internal;
use function Kadet\Functional\Internal\callback;
use Kadet\Functional\Exception\EmptyCollectionException;
/**
* Yields [key, value] pairs of $data from the end, without copying it where the container allows.
*
* @internal
*
* @param iterable<mixed, mixed> $data
*
* @return \Generator<int, array{mixed, mixed}>
*/
function reversedEntries(iterable $data): \Generator
{
if (is_array($data)) {
for (end($data); ($key = key($data)) !== null; prev($data)) {
yield [$key, current($data)];
}
} elseif ($data instanceof \Ds\Seq || $data instanceof \Ds\Set) {
for ($index = \count($data) - 1; $index >= 0; $index--) {
yield [$index, $data->get($index)];
}
} elseif ($data instanceof \Ds\Map) {
foreach ($data->pairs()->reversed() as $pair) {
yield [$pair->key, $pair->value];
}
} else {
$entries = [];
foreach ($data as $key => $value) {
$entries[] = [$key, $value];
}
yield from array_reverse($entries);
}
}
/**
* Shared implementation of {@see first()} and {@see find()}, so that the exception names the called function.
*
* @internal
*
* @return \Closure(iterable<mixed, mixed>): mixed
*/
function firstOf(string $name, ?callable $predicate, bool $required): \Closure
{
$predicate = $predicate === null ? null : callback($predicate);
return static function (iterable $data) use ($name, $predicate, $required) {
foreach ($data as $key => $value) {
if ($predicate === null || $predicate($value, $key)) {
return $value;
}
}
if ($required) {
throw EmptyCollectionException::noMatch($name, $predicate !== null);
}
return null;
};
}
/**
* Shared implementation of {@see min()} (`$direction` -1) and {@see max()} (1).
*
* @internal
*
* @return \Closure(iterable<mixed, mixed>): mixed
*/
function extremum(int $direction, ?callable $selector): \Closure
{
$selector = $selector === null ? static fn ($value) => $value : callback($selector);
return static function (iterable $data) use ($direction, $selector) {
$best = null;
$first = true;
foreach ($data as $key => $value) {
$value = $selector($value, $key);
if ($first || ($value <=> $best) === $direction) {
$best = $value;
$first = false;
}
}
return $best;
};
}

View File

@ -0,0 +1,393 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/**
* Lazy collection transforms.
*
* Every function returns a closure that takes any iterable and returns a generator, so nothing is done until the
* result is consumed and a chain walks its source once. Keys are preserved, except for {@see values()},
* {@see keys()}, {@see flatMap()}, {@see prepend()}, {@see append()}, {@see concat()} and {@see chunk()}, which yield
* sequential keys.
* End a chain with a collector or query from `Kadet\Functional\Collections`, e.g. `toArray()`.
*
* The eager transforms in `Kadet\Functional\Collections` are built on these, so both flavours behave the same.
* Callbacks receive `($value, $key)`. There are no sorting, grouping or keyed `merge` functions here, as those
* need the whole input.
*/
namespace Kadet\Functional\Collections\Lazy;
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;
/**
* Transforms each value with `$fn($value, $key)`, keeping the keys.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): TResult $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<TKey, TResult>
*/
function map(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): \Generator {
foreach ($input as $key => $value) {
yield $key => $fn($value, $key);
}
};
}
/**
* Keeps the values for which `$fn($value, $key)` is truthy, or the truthy values when `$fn` is null.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function filter(?callable $fn = null): \Closure
{
$fn = $fn === null ? static fn ($value) => $value : callback($fn);
return static function (iterable $input) use ($fn): \Generator {
foreach ($input as $key => $value) {
if ($fn($value, $key)) {
yield $key => $value;
}
}
};
}
/**
* Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function reject(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): \Generator {
foreach ($input as $key => $value) {
if (!$fn($value, $key)) {
yield $key => $value;
}
}
};
}
/**
* Maps each value to an iterable with `$fn($value, $key)` and flattens the results one level, with sequential keys.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): iterable<mixed, TResult> $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<int, TResult>
*/
function flatMap(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): \Generator {
foreach ($input as $key => $value) {
$result = $fn($value, $key);
if (!is_iterable($result)) {
throw new \UnexpectedValueException(sprintf(
'flatMap(): the callback must return an iterable, got %s.',
get_debug_type($result),
));
}
foreach ($result as $item) {
yield $item;
}
}
};
}
/**
* Drops the keys, yielding the values with sequential keys.
*
* @return \Closure<TValue>(iterable<mixed, TValue>): \Generator<int, TValue>
*/
function values(): \Closure
{
return static function (iterable $input): \Generator {
foreach ($input as $value) {
yield $value;
}
};
}
/**
* Yields the keys as values, with sequential keys.
*
* @return \Closure<TKey>(iterable<TKey, mixed>): \Generator<int, TKey>
*/
function keys(): \Closure
{
return static function (iterable $input): \Generator {
foreach ($input as $key => $_) {
yield $key;
}
};
}
/**
* Removes duplicate values, keeping the first occurrence. Values are compared strictly, objects by identity.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function unique(): \Closure
{
return uniqueBy(static fn ($value) => $value);
}
/**
* Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence.
* Selected values are compared strictly, objects by identity.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $selector
*
* @return \Closure(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function uniqueBy(callable $selector): \Closure
{
$selector = callback($selector);
return static function (iterable $input) use ($selector): \Generator {
$seen = seen();
foreach ($input as $key => $value) {
if (!$seen($selector($value, $key))) {
yield $key => $value;
}
}
};
}
/**
* Takes `$length` values (all remaining when null) starting at position `$offset`, like `array_slice()`.
*
* Negative `$offset` counts from the end, negative `$length` stops that many values before the end. With
* non-negative arguments the source is consumed only as far as needed; a negative argument needs the whole input.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function slice(int $offset, ?int $length = null): \Closure
{
return static function (iterable $input) use ($offset, $length): \Generator {
if ($offset < 0 || ($length !== null && $length < 0)) {
yield from unpairs(array_slice(entries($input), $offset, $length));
return;
}
if ($length === 0) {
return;
}
$position = 0;
$taken = 0;
foreach ($input as $key => $value) {
if ($position++ < $offset) {
continue;
}
yield $key => $value;
if (++$taken === $length) {
return;
}
}
};
}
/**
* Takes the first `$n` values. See {@see slice()}.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function take(int $n): \Closure
{
if ($n < 0) {
throw new \InvalidArgumentException(sprintf('take(): count must be >= 0, got %d', $n));
}
return slice(0, $n);
}
/**
* Skips the first `$n` values. See {@see slice()}.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function skip(int $n): \Closure
{
if ($n < 0) {
throw new \InvalidArgumentException(sprintf('skip(): count must be >= 0, got %d', $n));
}
return slice($n);
}
/**
* Splits the values into arrays of `$size` (the last one may be shorter), like `array_chunk()`, yielding each as soon
* as it is full, so the source is never held whole.
*
* Chunks are lists unless `$preserveKeys` is true, which keeps the keys in them. Keys must then be valid array keys,
* and values under a key repeated within one chunk replace each other, as in any array.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Generator<int, array<TKey, TValue>>
*/
function chunk(int $size, bool $preserveKeys = false): \Closure
{
if ($size < 1) {
throw new \InvalidArgumentException(sprintf('chunk(): size must be >= 1, got %d', $size));
}
return static function (iterable $input) use ($size, $preserveKeys): \Generator {
$chunk = [];
foreach ($input as $key => $value) {
if ($preserveKeys) {
$chunk[$key] = $value;
} else {
$chunk[] = $value;
}
if (count($chunk) === $size) {
yield $chunk;
$chunk = [];
}
}
if ($chunk !== []) {
yield $chunk;
}
};
}
/**
* Puts `$values` before the values of the input, with sequential keys.
*
* @template TValue = mixed
*
* @param TValue ...$values
*
* @return \Closure(iterable<mixed, TValue>): \Generator<int, TValue>
*/
function prepend(mixed ...$values): \Closure
{
return static function (iterable $input) use ($values): \Generator {
foreach ($values as $value) {
yield $value;
}
foreach ($input as $value) {
yield $value;
}
};
}
/**
* Puts `$values` after the values of the input, with sequential keys.
*
* @template TValue = mixed
*
* @param TValue ...$values
*
* @return \Closure(iterable<mixed, TValue>): \Generator<int, TValue>
*/
function append(mixed ...$values): \Closure
{
return concat($values);
}
/**
* Puts the values of `$other` after the values of the input, with sequential keys.
*
* @template TValue = mixed
*
* @param iterable<mixed, TValue> $other
*
* @return \Closure(iterable<mixed, TValue>): \Generator<int, TValue>
*/
function concat(iterable $other): \Closure
{
return static function (iterable $input) use ($other): \Generator {
foreach ($input as $value) {
yield $value;
}
foreach ($other as $value) {
yield $value;
}
};
}
/**
* Calls `$fn($value, $key)` for each value, for its side effects, and yields every element through unchanged.
*
* The return value of `$fn` is ignored.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function each(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): \Generator {
foreach ($input as $key => $value) {
$fn($value, $key);
yield $key => $value;
}
};
}
/**
* Alias of {@see each()}.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
* @return \Closure(iterable<TKey, TValue>): \Generator<TKey, TValue>
*/
function tap(callable $fn): \Closure
{
return each($fn);
}

View File

@ -0,0 +1,149 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/**
* Collectors: turn any iterable into a concrete collection.
*
* Everything except {@see toArray()} produces php-ds containers and throws {@see DsUnavailableException} when
* php-ds is not available. The exception is thrown when the collector is built, not when it is applied.
*/
namespace Kadet\Functional\Collections;
use Kadet\Functional\Exception\DsUnavailableException;
use function Kadet\Functional\Internal\callback;
use function Kadet\Functional\Collections\Internal\dsAvailable;
/**
* Indexes the values by `$selector($value, $key)` into a `Ds\Map`. The last value wins on a duplicate key.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TIndex = mixed
*
* @param callable(TValue, TKey): TIndex $selector
*
* @return \Closure(iterable<TKey, TValue>): \Ds\Map<TIndex, TValue>
*/
function keyBy(callable $selector): \Closure
{
dsAvailable() || throw DsUnavailableException::for('keyBy');
$selector = callback($selector);
return static function (iterable $input) use ($selector): \Ds\Map {
$map = new \Ds\Map();
foreach ($input as $key => $value) {
$map->put($selector($value, $key), $value);
}
return $map;
};
}
/**
* Groups the values by `$selector($value, $key)` into a `Ds\Map` of `Ds\Seq`. Groups and their values keep the
* order in which they were encountered.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TGroup = mixed
*
* @param callable(TValue, TKey): TGroup $selector
*
* @return \Closure(iterable<TKey, TValue>): \Ds\Map<TGroup, \Ds\Seq<TValue>>
*/
function groupBy(callable $selector): \Closure
{
dsAvailable() || throw DsUnavailableException::for('groupBy');
$selector = callback($selector);
return static function (iterable $input) use ($selector): \Ds\Map {
$map = new \Ds\Map();
foreach ($input as $key => $value) {
$group = $selector($value, $key);
if (!$map->hasKey($group)) {
$map->put($group, new \Ds\Seq());
}
$map->get($group)->push($value);
}
return $map;
};
}
/**
* Collects the values into a `Ds\Seq`, dropping the keys.
*
* @return \Closure<TValue>(iterable<mixed, TValue>): \Ds\Seq<TValue>
*/
function toSeq(): \Closure
{
dsAvailable() || throw DsUnavailableException::for('toSeq');
return static fn (iterable $input): \Ds\Seq => new \Ds\Seq($input);
}
/**
* Collects the entries into a `Ds\Map`, keeping the keys (of any type).
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): \Ds\Map<TKey, TValue>
*/
function toMap(): \Closure
{
dsAvailable() || throw DsUnavailableException::for('toMap');
return static function (iterable $input): \Ds\Map {
$map = new \Ds\Map();
foreach ($input as $key => $value) {
$map->put($key, $value);
}
return $map;
};
}
/**
* Collects the values into a `Ds\Set`, dropping the keys.
*
* @return \Closure<TValue>(iterable<mixed, TValue>): \Ds\Set<TValue>
*/
function toSet(): \Closure
{
dsAvailable() || throw DsUnavailableException::for('toSet');
return static fn (iterable $input): \Ds\Set => new \Ds\Set($input);
}
/**
* Collects the entries into an array, keeping the keys. Works without php-ds.
*
* Throws when a key can't be an array key (e.g. an object key of a `Ds\Map`).
*
* @return \Closure<TKey of array-key, TValue>(iterable<TKey, TValue>): array<TKey, TValue>
*/
function toArray(): \Closure
{
return static function (iterable $input): array {
$array = [];
foreach ($input as $key => $value) {
if (!is_scalar($key) && $key !== null) {
throw new \InvalidArgumentException(sprintf(
'toArray(): key of type %s cannot be an array key',
get_debug_type($key),
));
}
$array[$key] = $value;
}
return $array;
};
}

355
src/Collections/queries.php Normal file
View File

@ -0,0 +1,355 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/**
* Collection queries.
*
* Every function returns a closure that takes any iterable (generators included) and gives a single result, so they
* end a pipeline, eager or lazy. They stop reading as soon as the answer is known.
*
* Callbacks receive `($value, $key)`, also for `Ds\Map`. Inside this namespace `count`, `min`, `max` and `join`
* shadow the PHP builtins, so library code uses `\count()` and friends.
*/
namespace Kadet\Functional\Collections;
use Kadet\Functional\Exception\EmptyCollectionException;
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;
/**
* Returns the first value for which `$predicate($value, $key)` is truthy, or the first value without a predicate.
*
* Returns null when nothing matches; with `$required` it throws instead, so a found `null` stays distinguishable.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $predicate
*
* @return ($predicate is null
* ? \Closure<V>(iterable<mixed, V>): ?V
* : \Closure(iterable<TKey, TValue>): ?TValue)
*
* @throws EmptyCollectionException when `$required` is set and nothing matched
*/
function first(?callable $predicate = null, bool $required = false): \Closure
{
return firstOf('first', $predicate, $required);
}
/**
* Returns the last value for which `$predicate($value, $key)` is truthy, or the last value without a predicate.
*
* Arrays and php-ds containers are read from the end, other iterables are read to the end. Returns null when
* nothing matches; with `$required` it throws instead.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $predicate
*
* @return ($predicate is null
* ? \Closure<V>(iterable<mixed, V>): ?V
* : \Closure(iterable<TKey, TValue>): ?TValue)
*
* @throws EmptyCollectionException when `$required` is set and nothing matched
*/
function last(?callable $predicate = null, bool $required = false): \Closure
{
$predicate = $predicate === null ? null : callback($predicate);
return static function (iterable $data) use ($predicate, $required) {
$reversible = is_array($data) || $data instanceof \Ds\Seq || $data instanceof \Ds\Set
|| $data instanceof \Ds\Map;
if ($reversible) {
foreach (reversedEntries($data) as [$key, $value]) {
if ($predicate === null || $predicate($value, $key)) {
return $value;
}
}
} else {
$found = false;
$result = null;
foreach ($data as $key => $value) {
if ($predicate === null || $predicate($value, $key)) {
$found = true;
$result = $value;
}
}
if ($found) {
return $result;
}
}
if ($required) {
throw EmptyCollectionException::noMatch('last', $predicate !== null);
}
return null;
};
}
/**
* Alias of {@see first()} with a mandatory predicate: returns the first value matching `$predicate($value, $key)`.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
* @return \Closure(iterable<TKey, TValue>): ?TValue
*
* @throws EmptyCollectionException when `$required` is set and nothing matched
*/
function find(callable $predicate, bool $required = false): \Closure
{
return firstOf('find', $predicate, $required);
}
/**
* Whether the collection holds `$value`, compared strictly (`Ds\Key::equals` when `$value` implements `Ds\Key`).
*
* @return \Closure(iterable<mixed, mixed>): bool
*/
function contains(mixed $value): \Closure
{
$isKey = interface_exists(\Ds\Key::class) && $value instanceof \Ds\Key;
return static function (iterable $data) use ($value, $isKey): bool {
foreach ($data as $item) {
if ($isKey ? $item instanceof \Ds\Key && $value->equals($item) : $item === $value) {
return true;
}
}
return false;
};
}
/**
* Whether any value satisfies `$predicate($value, $key)`. False for an empty collection.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
* @return \Closure(iterable<TKey, TValue>): bool
*/
function any(callable $predicate): \Closure
{
$predicate = callback($predicate);
return static function (iterable $data) use ($predicate): bool {
foreach ($data as $key => $value) {
if ($predicate($value, $key)) {
return true;
}
}
return false;
};
}
/**
* Alias of {@see any()}.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
* @return \Closure(iterable<TKey, TValue>): bool
*/
function some(callable $predicate): \Closure
{
return any($predicate);
}
/**
* Whether every value satisfies `$predicate($value, $key)`. True for an empty collection.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $predicate
*
* @return \Closure(iterable<TKey, TValue>): bool
*/
function every(callable $predicate): \Closure
{
$predicate = callback($predicate);
return static function (iterable $data) use ($predicate): bool {
foreach ($data as $key => $value) {
if (!$predicate($value, $key)) {
return false;
}
}
return true;
};
}
/**
* Counts the values. Containers that know their size are not iterated.
*
* @return \Closure(iterable<mixed, mixed>): int
*/
function count(): \Closure
{
return static function (iterable $data): int {
if (is_array($data) || $data instanceof \Countable) {
return \count($data);
}
$count = 0;
foreach ($data as $_) {
$count++;
}
return $count;
};
}
/**
* Whether the collection has no values. Reads at most one value.
*
* @return \Closure(iterable<mixed, mixed>): bool
*/
function isEmpty(): \Closure
{
return static function (iterable $data): bool {
if (is_array($data) || $data instanceof \Countable) {
return \count($data) === 0;
}
foreach ($data as $_) {
return false;
}
return true;
};
}
/**
* The smallest value (compared with `<=>`), or the smallest of `$selector($value, $key)` when given. Null when empty.
*
* With a selector this returns the selected value, not the element it was selected from.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TSelected = mixed
*
* @param (callable(TValue, TKey): TSelected)|null $selector
*
* @return ($selector is null
* ? \Closure<V>(iterable<mixed, V>): ?V
* : \Closure(iterable<TKey, TValue>): ?TSelected)
*/
function min(?callable $selector = null): \Closure
{
return extremum(-1, $selector);
}
/**
* The largest value (compared with `<=>`), or the largest of `$selector($value, $key)` when given. Null when empty.
*
* With a selector this returns the selected value, not the element it was selected from.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TSelected = mixed
*
* @param (callable(TValue, TKey): TSelected)|null $selector
*
* @return ($selector is null
* ? \Closure<V>(iterable<mixed, V>): ?V
* : \Closure(iterable<TKey, TValue>): ?TSelected)
*/
function max(?callable $selector = null): \Closure
{
return extremum(1, $selector);
}
/**
* Folds the collection into a single value with `$fn($carry, $value, $key)`, starting from `$initial`.
*
* Internal functions (e.g. `max(...)`) and functions declaring two parameters get only `($carry, $value)`.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TCarry = mixed
*
* @param callable(TCarry, TValue, TKey): TCarry $fn
* @param TCarry $initial
*
* @return \Closure(iterable<TKey, TValue>): TCarry
*/
function reduce(callable $fn, mixed $initial = null): \Closure
{
// The reducer takes the carry first: an internal one, or one declaring two parameters, gets ($carry, $value).
$fn = callback($fn, 2);
return static function (iterable $data) use ($fn, $initial) {
$carry = $initial;
foreach ($data as $key => $value) {
$carry = $fn($carry, $value, $key);
}
return $carry;
};
}
/**
* Sums the values, or `$selector($value, $key)` when given. An empty collection sums to 0.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): (int|float))|null $selector
*
* @return \Closure(iterable<TKey, TValue>): (int|float)
*/
function sum(?callable $selector = null): \Closure
{
$selector = $selector === null ? static fn ($value) => $value : callback($selector);
return static function (iterable $data) use ($selector): int|float {
$sum = 0;
foreach ($data as $key => $value) {
$sum += $selector($value, $key);
}
return $sum;
};
}
/**
* Joins the values into a string with `$glue`.
*
* @return \Closure(iterable<mixed, string|\Stringable|int|float>): string
*/
function join(string $glue = ''): \Closure
{
return static function (iterable $data) use ($glue): string {
$parts = [];
foreach ($data as $value) {
$parts[] = (string) $value;
}
return implode($glue, $parts);
};
}

View File

@ -0,0 +1,658 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/**
* Eager collection transforms.
*
* Every function returns a closure that takes the data, so they compose with the pipe operator. The result is the
* same kind of container as the input: an array gives an array (keys preserved, unless stated otherwise), `Ds\Seq`
* gives `Ds\Seq`, `Ds\Map` gives `Ds\Map` and `Ds\Set` gives `Ds\Set`. Any other iterable (e.g. a generator) is
* consumed and gives a `Ds\Seq` of the results, or a list array when php-ds is unavailable.
*
* Callbacks receive `($value, $key)`, also for `Ds\Map`.
*/
namespace Kadet\Functional\Collections;
use Kadet\Functional\Collections\Lazy as lazy;
use function Kadet\Functional\Internal\callback;
use function Kadet\Functional\Internal\takesByReference;
use function Kadet\Functional\Collections\Internal\entries;
use function Kadet\Functional\Collections\Internal\iterated;
use function Kadet\Functional\Collections\Internal\keyedLike;
use function Kadet\Functional\Collections\Internal\seen;
use function Kadet\Functional\Collections\Internal\listLike;
use function Kadet\Functional\Collections\Internal\mapInOrder;
use function Kadet\Functional\Collections\Internal\seqOf;
use function Kadet\Functional\Collections\Internal\unpairs;
use function Kadet\Functional\Collections\Internal\valuesOf;
use function Kadet\Functional\compile;
/**
* Transforms each value with `$fn($value, $key)`, keeping the keys.
*
* For `Ds\Set` this follows `Ds\Set::map`, so equal results collapse into one.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): TResult $fn
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TResult>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TResult>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TResult>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TResult> : \Ds\Seq<TResult>|list<TResult>))))
*/
function map(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): iterable {
if (is_array($input)) {
$result = [];
foreach ($input as $key => $value) {
$result[$key] = $fn($value, $key);
}
return $result;
}
if ($input instanceof \Ds\Map) {
return $input->map(static fn ($key, $value) => $fn($value, $key));
}
$result = [];
foreach (iterated($input) as $key => $value) {
$result[] = $fn($value, $key);
}
return listLike($input, $result);
};
}
/**
* Keeps the values for which `$fn($value, $key)` is truthy, or the truthy values when `$fn` is null.
*
* Keys are preserved for arrays and `Ds\Map`; `Ds\Seq` is reindexed.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param (callable(TValue, TKey): mixed)|null $fn
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TValue>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TValue>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TValue>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TValue> : \Ds\Seq<TValue>|list<TValue>))))
*/
function filter(?callable $fn = null): \Closure
{
$fn = $fn === null ? static fn ($value) => $value : callback($fn);
// array_filter() passes values, not variables, so a callback taking a parameter by reference would warn on each
// call. Through this wrapper it gets variables; as before, its changes don't reach the result.
$native = takesByReference($fn) ? static fn ($value, $key) => $fn($value, $key) : $fn;
return static function (iterable $input) use ($fn, $native): iterable {
if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => $fn($value, $key));
}
$values = iterated($input);
if (is_array($values)) {
$values = array_filter($values, $native, ARRAY_FILTER_USE_BOTH);
return is_array($input) ? $values : listLike($input, $values);
}
$result = [];
foreach ($values as $key => $value) {
if ($fn($value, $key)) {
$result[] = $value;
}
}
return listLike($input, $result);
};
}
/**
* Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TValue>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TValue>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TValue>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TValue> : \Ds\Seq<TValue>|list<TValue>))))
*/
function reject(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): iterable {
if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => !$fn($value, $key));
}
$result = [];
if (is_array($input)) {
foreach ($input as $key => $value) {
if (!$fn($value, $key)) {
$result[$key] = $value;
}
}
return $result;
}
foreach (iterated($input) as $key => $value) {
if (!$fn($value, $key)) {
$result[] = $value;
}
}
return listLike($input, $result);
};
}
/**
* Maps each value to an iterable with `$fn($value, $key)` and flattens the results one level into a list.
*
* Arrays give a list array, `Ds\Set` gives `Ds\Set`, anything else gives `Ds\Seq`.
*
* @template TKey = mixed
* @template TValue = mixed
* @template TResult = mixed
*
* @param callable(TValue, TKey): iterable<mixed, TResult> $fn
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? list<TResult>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TResult>
* : (TIn is \Ds\Seq<mixed>|\Ds\Map<mixed, mixed> ? \Ds\Seq<TResult> : \Ds\Seq<TResult>|list<TResult>)))
*/
function flatMap(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): iterable {
$result = [];
foreach (iterated($input) as $key => $value) {
$items = $fn($value, $key);
if (!is_iterable($items)) {
throw new \UnexpectedValueException(sprintf(
'flatMap(): the callback must return an iterable, got %s.',
get_debug_type($items),
));
}
foreach ($items as $item) {
$result[] = $item;
}
}
return listLike($input, $result);
};
}
/**
* Drops the keys: an array gives a list array, `Ds\Map` gives `Ds\Seq`.
*
* @return \Closure<TValue>(iterable<mixed, TValue>): iterable<int, TValue>
*/
function values(): \Closure
{
return static fn (iterable $input): iterable => listLike($input, valuesOf($input));
}
/**
* Gives the keys as a list: a list array for an array, a `Ds\Seq` for anything else.
*
* @return \Closure<TKey>(iterable<TKey, mixed>): iterable<int, TKey>
*/
function keys(): \Closure
{
return static function (iterable $input): iterable {
if (is_array($input)) {
return array_keys($input);
}
$keys = [];
foreach (iterated($input) as $key => $_) {
$keys[] = $key;
}
return seqOf($keys);
};
}
/**
* Removes duplicate values, keeping the first occurrence. Values are compared strictly, objects by identity.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): iterable<TKey, TValue>
*/
function unique(): \Closure
{
return uniqueBy(static fn ($value) => $value);
}
/**
* Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence.
* Selected values are compared strictly, objects by identity.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $selector
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TValue>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TValue>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TValue>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TValue> : \Ds\Seq<TValue>|list<TValue>))))
*/
function uniqueBy(callable $selector): \Closure
{
$selector = callback($selector);
return static function (iterable $input) use ($selector): iterable {
$seen = seen();
if ($input instanceof \Ds\Map) {
return $input->filter(static fn ($key, $value) => !$seen($selector($value, $key)));
}
$result = [];
if (is_array($input)) {
foreach ($input as $key => $value) {
if (!$seen($selector($value, $key))) {
$result[$key] = $value;
}
}
return $result;
}
foreach (iterated($input) as $key => $value) {
if (!$seen($selector($value, $key))) {
$result[] = $value;
}
}
return listLike($input, $result);
};
}
/**
* Sorts the values with `$comparator($a, $b)` (an int like `<=>`), or with `<=>` when it is null.
*
* The sort is stable and keeps the keys of arrays and `Ds\Map`. The input is never modified. Without a comparator,
* enums are ordered differently from `<=>` (see "What you get back" in the README).
*
* @template TValue = mixed
*
* @param (callable(TValue, TValue): int)|null $comparator
*
* @return \Closure<TKey>(iterable<TKey, TValue>): iterable<TKey, TValue>
*/
function sort(?callable $comparator = null): \Closure
{
if ($comparator === null) {
// The native SORT_REGULAR sorts compare like `<=>` and are stable.
return static function (iterable $input): iterable {
if (is_array($input)) {
asort($input);
return $input;
}
if ($input instanceof \Ds\Map) {
$keys = $input->keys()->toArray();
$values = $input->values()->toArray();
asort($values);
return mapInOrder($keys, $values, $values);
}
$values = valuesOf($input);
\sort($values);
return listLike($input, $values);
};
}
// Comparators always take two values, so they are compiled without the ($value, $key) adaptation.
$comparator = compile($comparator);
return static function (iterable $input) use ($comparator): iterable {
if (is_array($input)) {
uasort($input, $comparator);
return $input;
}
if ($input instanceof \Ds\Map) {
$entries = entries($input);
usort($entries, static fn ($a, $b) => $comparator($a[1], $b[1]));
return keyedLike($input, unpairs($entries));
}
$values = valuesOf($input);
usort($values, $comparator);
return listLike($input, $values);
};
}
/**
* Sorts the values by `$selector($value, $key)`, compared with `<=>`, in ascending or descending order.
*
* The sort is stable (equal values keep their order in both directions) and keeps the keys of arrays and
* `Ds\Map`. The selector is called once per value. The input is never modified. Enum keys are ordered differently
* from `<=>` (see "What you get back" in the README).
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $selector
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TValue>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TValue>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TValue>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TValue> : \Ds\Seq<TValue>|list<TValue>))))
*/
function sortBy(callable $selector, bool $descending = false): \Closure
{
$selector = callback($selector);
// The selected values are sorted natively: SORT_REGULAR compares like `<=>`, and PHP's sorts are stable, also
// arsort() for ties.
return static function (iterable $input) use ($selector, $descending): iterable {
if (is_array($input)) {
$order = [];
foreach ($input as $key => $value) {
$order[$key] = $selector($value, $key);
}
$descending ? arsort($order) : asort($order);
$result = [];
foreach ($order as $key => $_) {
$result[$key] = $input[$key];
}
return $result;
}
// Keys of `Ds\Map` can be objects, so the entries are sorted by position.
$keys = [];
$values = [];
$order = [];
foreach (iterated($input) as $key => $value) {
$keys[] = $key;
$values[] = $value;
$order[] = $selector($value, $key);
}
$descending ? arsort($order) : asort($order);
if ($input instanceof \Ds\Map) {
return mapInOrder($keys, $values, $order);
}
$result = [];
foreach ($order as $position => $_) {
$result[] = $values[$position];
}
return listLike($input, $result);
};
}
/**
* Takes `$length` values (all remaining when null) starting at position `$offset`, like `array_slice()`.
*
* Negative `$offset` counts from the end, negative `$length` stops that many values before the end. Keys of arrays
* and `Ds\Map` are preserved. With non-negative arguments a generator is consumed only as far as needed.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): iterable<TKey, TValue>
*/
function slice(int $offset, ?int $length = null): \Closure
{
$lazy = lazy\slice($offset, $length);
return static function (iterable $input) use ($offset, $length, $lazy): iterable {
if (is_array($input)) {
return array_slice($input, $offset, $length, true);
}
if ($input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set) {
return $length === null ? $input->slice($offset) : $input->slice($offset, $length);
}
return keyedLike($input, $lazy($input));
};
}
/**
* Takes the first `$n` values. See {@see slice()}.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): iterable<TKey, TValue>
*/
function take(int $n): \Closure
{
if ($n < 0) {
throw new \InvalidArgumentException(sprintf('take(): count must be >= 0, got %d', $n));
}
return slice(0, $n);
}
/**
* Skips the first `$n` values. See {@see slice()}.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): iterable<TKey, TValue>
*/
function skip(int $n): \Closure
{
if ($n < 0) {
throw new \InvalidArgumentException(sprintf('skip(): count must be >= 0, got %d', $n));
}
return slice($n);
}
/**
* Splits the values into arrays of `$size` (the last one may be shorter), like `array_chunk()`. See
* {@see Lazy\chunk()}.
*
* The chunks come in a list array for an array, and in a `Ds\Seq` (a list array without php-ds) for anything else.
*
* @return \Closure<TKey, TValue>(iterable<TKey, TValue>): iterable<int, array<TKey, TValue>>
*/
function chunk(int $size, bool $preserveKeys = false): \Closure
{
$lazy = lazy\chunk($size, $preserveKeys);
return static function (iterable $input) use ($lazy): iterable {
$chunks = iterator_to_array($lazy(iterated($input)), false);
return is_array($input) ? $chunks : seqOf($chunks);
};
}
/**
* Puts `$values` before the values of the input, as a list (keys are dropped).
*
* @template TValue = mixed
*
* @param TValue ...$values
*
* @return \Closure(iterable<mixed, TValue>): iterable<int, TValue>
*/
function prepend(mixed ...$values): \Closure
{
$values = array_values($values);
return static fn (iterable $input): iterable => listLike($input, array_merge($values, valuesOf($input)));
}
/**
* Puts `$values` after the values of the input, as a list (keys are dropped).
*
* @template TValue = mixed
*
* @param TValue ...$values
*
* @return \Closure(iterable<mixed, TValue>): iterable<int, TValue>
*/
function append(mixed ...$values): \Closure
{
return concat($values);
}
/**
* Puts the values of `$other` after the values of the input, as a list (keys are dropped).
*
* @template TValue = mixed
*
* @param iterable<mixed, TValue> $other
*
* @return \Closure(iterable<mixed, TValue>): iterable<int, TValue>
*/
function concat(iterable $other): \Closure
{
return static fn (iterable $input): iterable => listLike($input, array_merge(valuesOf($input), valuesOf($other)));
}
/**
* Merges `$other` into the input by key, later keys win. Integer keys of arrays are kept, unlike `array_merge()`.
*
* Arrays and `Ds\Map` are merged by key; for `Ds\Seq`, `Ds\Set` and other iterables the values are appended.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param iterable<TKey, TValue> $other
*
* @return \Closure(iterable<TKey, TValue>): iterable<TKey, TValue>
*/
function merge(iterable $other): \Closure
{
return static function (iterable $input) use ($other): iterable {
if (is_array($input)) {
foreach ($other as $key => $value) {
if (!is_scalar($key) && $key !== null) {
throw new \InvalidArgumentException(sprintf(
'merge(): key of type %s cannot be an array key',
get_debug_type($key),
));
}
$input[$key] = $value;
}
return $input;
}
if ($input instanceof \Ds\Map) {
$result = $input->copy();
$result->putAll($other);
return $result;
}
return listLike($input, array_merge(valuesOf($input), valuesOf($other)));
};
}
/**
* Calls `$fn($value, $key)` for each value, for its side effects, and returns the input unchanged.
*
* The return value of `$fn` is ignored. Arrays and php-ds collections are returned as they are; any other
* iterable is consumed and its values are returned as a `Ds\Seq` (or a list array without php-ds).
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TValue>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TValue>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TValue>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TValue> : \Ds\Seq<TValue>|list<TValue>))))
*/
function each(callable $fn): \Closure
{
$fn = callback($fn);
return static function (iterable $input) use ($fn): iterable {
if (is_array($input) || $input instanceof \Ds\Seq || $input instanceof \Ds\Map || $input instanceof \Ds\Set) {
foreach (iterated($input) as $key => $value) {
$fn($value, $key);
}
return $input;
}
$values = [];
foreach ($input as $key => $value) {
$fn($value, $key);
$values[] = $value;
}
return listLike($input, $values);
};
}
/**
* Alias of {@see each()}.
*
* @template TKey = mixed
* @template TValue = mixed
*
* @param callable(TValue, TKey): mixed $fn
*
* @return \Closure<TIn of iterable<mixed, mixed>>(TIn): (TIn is array
* ? array<TKey, TValue>
* : (TIn is \Ds\Map<mixed, mixed>
* ? \Ds\Map<TKey, TValue>
* : (TIn is \Ds\Set<mixed>
* ? \Ds\Set<TValue>
* : (TIn is \Ds\Seq<mixed> ? \Ds\Seq<TValue> : \Ds\Seq<TValue>|list<TValue>))))
*/
function tap(callable $fn): \Closure
{
return each($fn);
}

27
src/Compilable.php Normal file
View File

@ -0,0 +1,27 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* 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 like invoking this object, but does not go through it on each call.
*
* The differences:
* - 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 (`all()` and `any()` likewise give each predicate only
* the arguments it takes);
* - a compiled CurriedFunction calls its function with the arguments it gets, it does not curry further.
*/
public function compile(): \Closure;
}

View File

@ -0,0 +1,18 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional\Exception;
class DsUnavailableException extends FunctionalException
{
public static function for(string $function): self
{
return new self(sprintf('%s() needs php-ds: install ext-ds or require php-ds/php-ds', $function));
}
}

View File

@ -0,0 +1,18 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
namespace Kadet\Functional\Exception;
class EmptyCollectionException extends FunctionalException
{
public static function noMatch(string $function, bool $filtered): self
{
return new self(sprintf('%s(): %s', $function, $filtered ? 'no element matched' : 'collection is empty'));
}
}

146
src/Internal/functions.php Normal file
View File

@ -0,0 +1,146 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
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;
/**
* The argument dispatch of apply(), with the arity of $callable already known: it calls $call when there are enough
* arguments and partially applies $callable otherwise.
*
* @internal
*
* @param array<mixed> $args
*/
function applyWithArity(callable $callable, callable $call, bool $variadic, int $required, int $all, array $args): mixed
{
switch (true) {
case $variadic:
return partial($callable, ...$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($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);
}
/**
* Whether a function takes any of its parameters by reference.
*
* @internal
*/
function takesByReference(\Closure $closure): bool
{
foreach ((new \ReflectionFunction($closure))->getParameters() as $parameter) {
if ($parameter->isPassedByReference()) {
return true;
}
}
return false;
}
/**
* 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;
}
}
/**
* Whether calling a closure with one argument is the same as calling it with more: a userland, non-variadic closure
* with one required parameter, as PHP drops the arguments past it. A closure wrapping it can then take one argument
* as well, which is cheaper to call than a variadic one.
*
* @internal
*/
function isUnary(\Closure $closure): bool
{
$reflection = new \ReflectionFunction($closure);
return !$reflection->isInternal()
&& !$reflection->isVariadic()
&& $reflection->getNumberOfParameters() === 1
&& $reflection->getNumberOfRequiredParameters() === 1;
}

View File

@ -10,9 +10,13 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Predicate;
class AllOfPredicate extends AbstractPredicate
use function Kadet\Functional\Internal\callback;
use function Kadet\Functional\Internal\isUnary;
class AllOfPredicate extends AbstractPredicate implements Compilable
{
/**
* Array of predicates to check if all are satisfied.
@ -41,4 +45,35 @@ class AllOfPredicate extends AbstractPredicate
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(static fn (Predicate $predicate) => callback($predicate), $this->predicates);
if ($predicates && count(array_filter($predicates, isUnary(...))) === count($predicates)) {
return static function ($value) use ($predicates): bool {
foreach ($predicates as $predicate) {
if (!$predicate($value)) {
return false;
}
}
return true;
};
}
return static function (mixed ...$args) use ($predicates): bool {
foreach ($predicates as $predicate) {
if (!$predicate(...$args)) {
return false;
}
}
return true;
};
}
}

View File

@ -10,9 +10,13 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Predicate;
class AnyOfPredicate extends AbstractPredicate
use function Kadet\Functional\Internal\callback;
use function Kadet\Functional\Internal\isUnary;
class AnyOfPredicate extends AbstractPredicate implements Compilable
{
/**
* Array of predicates to check if all are satisfied.
@ -42,4 +46,35 @@ class AnyOfPredicate extends AbstractPredicate
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(static fn (Predicate $predicate) => callback($predicate), $this->predicates);
if ($predicates && count(array_filter($predicates, isUnary(...))) === count($predicates)) {
return static function ($value) use ($predicates): bool {
foreach ($predicates as $predicate) {
if ($predicate($value)) {
return true;
}
}
return false;
};
}
return static function (mixed ...$args) use ($predicates): bool {
foreach ($predicates as $predicate) {
if ($predicate(...$args)) {
return true;
}
}
return false;
};
}
}

View File

@ -10,9 +10,13 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
class ClosurePredicate extends AbstractPredicate implements Decorator
use function Kadet\Functional\compile;
use function Kadet\Functional\Internal\isUnary;
class ClosurePredicate extends AbstractPredicate implements Decorator, Compilable
{
/**
* @var \Closure
@ -38,4 +42,21 @@ class ClosurePredicate extends AbstractPredicate implements Decorator
{
return $this->closure;
}
/**
* A closure already declared to return bool is returned as it is (compiled, if it wraps a Compilable).
*/
public function compile(): \Closure
{
$compiled = compile($this->closure);
$type = (new \ReflectionFunction($compiled))->getReturnType();
if ($type instanceof \ReflectionNamedType && $type->getName() === 'bool' && !$type->allowsNull()) {
return $compiled;
}
return isUnary($compiled)
? static fn ($value): bool => $compiled($value)
: static fn (mixed ...$args): bool => $compiled(...$args);
}
}

View File

@ -11,9 +11,10 @@ namespace Kadet\Functional\Predicate;
use function Kadet\Functional\{ all, any };
use Kadet\Functional\Compilable;
use Kadet\Functional\Predicate;
class ConstantPredicate extends AbstractPredicate
class ConstantPredicate extends AbstractPredicate implements Compilable
{
/** @var boolean */
private $value;
@ -58,6 +59,13 @@ class ConstantPredicate extends AbstractPredicate
return $this->value;
}
public function compile(): \Closure
{
$value = $this->value;
return static fn (mixed ...$args): bool => $value;
}
public static function always()
{
static $always;

View File

@ -10,10 +10,14 @@
namespace Kadet\Functional\Predicate;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
use Kadet\Functional\Predicate;
class NegatedPredicate extends AbstractPredicate implements Decorator
use function Kadet\Functional\compile;
use function Kadet\Functional\Internal\isUnary;
class NegatedPredicate extends AbstractPredicate implements Decorator, Compilable
{
/**
* Predicate to be negated.
@ -39,6 +43,15 @@ class NegatedPredicate extends AbstractPredicate implements Decorator
public function getDecorated()
{
$this->negated;
return $this->negated;
}
public function compile(): \Closure
{
$negated = compile($this->negated);
return isUnary($negated)
? static fn ($value): bool => !$negated($value)
: static fn (mixed ...$args): bool => !$negated(...$args);
}
}

View File

@ -19,7 +19,7 @@ use function Kadet\Functional\predicate;
*/
function equals($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $expected == $value;
});
}
@ -31,7 +31,7 @@ function equals($expected): Predicate
*/
function same($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value === $expected;
});
}
@ -43,7 +43,7 @@ function same($expected): Predicate
*/
function gt($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value > $expected;
});
}
@ -55,7 +55,7 @@ function gt($expected): Predicate
*/
function ge($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value >= $expected;
});
}
@ -67,7 +67,7 @@ function ge($expected): Predicate
*/
function lt($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value < $expected;
});
}
@ -79,7 +79,7 @@ function lt($expected): Predicate
*/
function le($expected): Predicate
{
return predicate(function ($value) use ($expected) {
return predicate(function ($value) use ($expected): bool {
return $value <= $expected;
});
}

View File

@ -49,9 +49,12 @@ function field(string $field, $predicate)
return predicate(f\pipe(t\field($field), $predicate));
}
/**
* @return \Kadet\Functional\Predicate
*/
function instance(string $class)
{
return new ClosurePredicate(function ($value) use ($class) {
return new ClosurePredicate(function ($value) use ($class): bool {
return $value instanceof $class;
});
}

View File

@ -9,11 +9,17 @@
namespace Kadet\Functional\Transforms;
/**
* @return \Closure(object): mixed
*/
function getter($property)
{
return method('get'.ucfirst($property));
}
/**
* @return \Closure(object): mixed
*/
function property($property)
{
return function ($object) use ($property) {
@ -25,6 +31,9 @@ function property($property)
};
}
/**
* @return \Closure(object): mixed
*/
function field($field)
{
return function ($object) use ($field) {
@ -32,6 +41,9 @@ function field($field)
};
}
/**
* @return \Closure(object): mixed
*/
function method($method, ...$args)
{
return function ($object) use ($method, $args) {

View File

@ -11,9 +11,13 @@ namespace Kadet\Functional\Utils;
use function Kadet\Functional\apply;
use function Kadet\Functional\Internal\applyWithArity;
use Kadet\Functional\Compilable;
use function Kadet\Functional\compile;
use Kadet\Functional\Decorator;
use function Kadet\Functional\reflect;
class FixedFunction implements Decorator
class FixedFunction implements Decorator, Compilable
{
/**
* @var callable
@ -39,4 +43,36 @@ 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) {
return applyWithArity($fixed, $call, $variadic, $required, $all, [$self, ...$args]);
};
return $self;
}
}

View File

@ -9,8 +9,12 @@
namespace Kadet\Functional\Utils;
use Kadet\Functional\Compilable;
class FunctionPipeline
use function Kadet\Functional\compile;
use function Kadet\Functional\Internal\isUnary;
class FunctionPipeline implements Compilable
{
/**
* @var callable[]
@ -20,13 +24,23 @@ class FunctionPipeline
/**
* FunctionPipeline constructor.
*
* @param callable[] $functions
* @param callable ...$functions
*/
public function __construct(callable ...$functions)
{
$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 ]));
@ -45,4 +59,49 @@ class FunctionPipeline
: $callable($arg);
}, new UnpackedArgs($args));
}
/**
* The first function gets all arguments, each next one the result of the previous one (spread when it is
* UnpackedArgs), like invoking the pipeline.
*/
public function compile(): \Closure
{
if (empty($this->functions)) {
return static fn (...$args) => new UnpackedArgs($args);
}
// A curried function decides on its own whether to call or keep currying, so it is called as is.
$functions = array_map(
static fn (callable $function) => $function instanceof CurriedFunction
? static fn (...$args) => $function(...$args)
: compile($function),
$this->functions,
);
$first = array_shift($functions);
if (empty($functions)) {
return $first;
}
// A first function taking one argument ignores any other, so the compiled pipeline can take one as well.
if (isUnary($first)) {
return static function ($value) use ($first, $functions) {
$result = $first($value);
foreach ($functions as $function) {
$result = $result instanceof UnpackedArgs ? $function(...$result->getArrayCopy()) : $function($result);
}
return $result;
};
}
return static function (...$args) use ($first, $functions) {
$result = $first(...$args);
foreach ($functions as $function) {
$result = $result instanceof UnpackedArgs ? $function(...$result->getArrayCopy()) : $function($result);
}
return $result;
};
}
}

View File

@ -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 = [];
@ -53,10 +55,7 @@ class PartiallyAppliedFunction implements Decorator
: $argument;
}
return array_merge(
array_values($result),
array_values($args)
);
return array_merge($result, array_values($args));
}
public function getDecorated()
@ -64,6 +63,55 @@ class PartiallyAppliedFunction implements Decorator
return $this->callable;
}
/**
* Internal functions throw when given too many arguments, so for a non-variadic internal target the compiled
* closure drops trailing arguments past its parameter count (e.g. the key passed by collection functions).
*/
public function compile(): \Closure
{
$callable = compile($this->callable);
$arguments = $this->arguments;
$placeholders = array_keys($arguments, PLACEHOLDER, true);
$target = new \ReflectionFunction($callable);
$extra = $target->isInternal() && !$target->isVariadic()
? max(0, $target->getNumberOfParameters() - count($arguments))
: null;
if (empty($placeholders)) {
return static fn (...$args) => $callable(...$arguments, ...array_slice(array_values($args), 0, $extra));
}
// The common shape, e.g. partial(str_pad(...), _, 8): one placeholder and no room for more arguments. An
// unfilled placeholder stays in place, as below.
if (count($placeholders) === 1 && $extra === 0) {
$position = $placeholders[0];
if ($position === 0) {
$rest = array_slice($arguments, 1);
return static fn ($value = PLACEHOLDER) => $callable($value, ...$rest);
}
return static function ($value = PLACEHOLDER) use ($callable, $arguments, $position) {
$arguments[$position] = $value;
return $callable(...$arguments);
};
}
return static function (...$args) use ($callable, $arguments, $placeholders, $extra) {
$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, $extra));
};
}
public static function fix(callable $callable, ...$args)
{
$applied = new self($callable, ...$args);

View File

@ -15,6 +15,8 @@ use Kadet\Functional\Utils\FunctionPipeline;
use Kadet\Functional\Utils\MemoizedFunction;
use Kadet\Functional\Utils\PartiallyAppliedFunction;
use function Kadet\Functional\Internal\applyWithArity;
function partial(callable $callable, ...$args): PartiallyAppliedFunction
{
return new PartiallyAppliedFunction($callable, ...$args);
@ -24,23 +26,15 @@ function apply(callable $callable, ...$args)
{
try {
$reflection = reflect($callable);
$required = $reflection->getNumberOfRequiredParameters();
$all = $reflection->getNumberOfParameters();
switch (true) {
case $reflection->isVariadic():
return partial($callable, ...$args);
case count($args) >= $all:
$applied = $callable(...array_slice($args, 0, $all));
return is_callable($applied) ? apply($applied, ...array_slice($args, $all)) : $applied;
case count($args) >= $required:
return $callable(...$args);
default:
return partial($callable, ...$args);
}
return applyWithArity(
$callable,
$callable,
$reflection->isVariadic(),
$reflection->getNumberOfRequiredParameters(),
$reflection->getNumberOfParameters(),
$args
);
} catch (\ReflectionException $exception) {
// if cannot gather necessary information for intelligent application just use normal one
return $callable(...$args);
@ -72,3 +66,25 @@ function memoize(callable $callable)
{
return new MemoizedFunction($callable);
}
/**
* Flattens a callable into a plain closure. Compilable objects (decorators, pipelines, predicates) are compiled, so
* calling the result does not go through them, also when given as a closure of their `__invoke` (e.g. `$pipe(...)`);
* any other callable is converted with closure().
*
* A closure of a curried function's `__invoke` (`$curried(...)`) is kept as it is, so it keeps currying; the compiled
* curried function itself calls its target and does not curry further.
*/
function compile(callable $callable): \Closure
{
if ($callable instanceof \Closure) {
$reflection = new \ReflectionFunction($callable);
$bound = $reflection->getClosureThis();
return $bound instanceof Compilable && !$bound instanceof CurriedFunction && $reflection->getName() === '__invoke'
? $bound->compile()
: $callable;
}
return $callable instanceof Compilable ? $callable->compile() : closure($callable);
}

View File

@ -41,13 +41,14 @@ function closure($callable)
case is_array($callable):
list($object, $method) = $callable;
$reflection = new \ReflectionObject($object);
$method = $reflection->getMethod($method);
// A static method ([Foo::class, 'method']) is given by its class name, not an object.
$class = is_string($object) ? $object : get_class($object);
$method = new \ReflectionMethod($object, $method);
if (!$method->isPublic()) {
$backtrace = debug_backtrace();
foreach ($backtrace as $trace) {
if (isset($trace['class']) && $trace['class'] === $reflection->name) {
if (isset($trace['class']) && $trace['class'] === $class) {
break;
}
@ -58,7 +59,7 @@ function closure($callable)
}
}
return $method->getClosure($object);
return $method->getClosure(is_string($object) ? null : $object);
default:
return \Closure::fromCallable($callable);
@ -102,13 +103,13 @@ function unpack($arguments)
function all(...$predicates)
{
$predicates = unpack($predicates);
return new AllOfPredicate(...array_map(predicate::class, $predicates));
return new AllOfPredicate(...array_map(predicate(...), $predicates));
}
function any(...$predicates)
{
$predicates = unpack($predicates);
return new AnyOfPredicate(...array_map(predicate::class, $predicates));
return new AnyOfPredicate(...array_map(predicate(...), $predicates));
}
function symbol($name = null)

95
tests/CallbackTest.php Normal file
View File

@ -0,0 +1,95 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Kadet\Functional as f;
use function Kadet\Functional\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 testSingleParamClosureIsNotWrapped()
{
// 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()
{
$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 testPartialOfInternalFunctionDoesNotGetKey()
{
$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", '>'));
$this->assertSame('>k=v', $callback('v', 'k'));
}
}

View File

@ -0,0 +1,108 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Ds\Map;
use Ds\Seq;
use Ds\Set;
use Kadet\Functional\Collections as c;
class CollectorsTest extends \PHPUnit\Framework\TestCase
{
private static function gen(iterable $entries): \Generator
{
foreach ($entries as $key => $value) {
yield $key => $value;
}
}
public function testKeyByLastWins()
{
$map = c\keyBy(fn ($v) => $v['id'])([['id' => 1, 'n' => 'a'], ['id' => 2, 'n' => 'b'], ['id' => 1, 'n' => 'c']]);
$this->assertInstanceOf(Map::class, $map);
$this->assertSame(2, $map->count());
$this->assertSame('c', $map->get(1)['n']);
$this->assertSame([1, 2], $map->keys()->toArray());
}
public function testKeyBySelectorReceivesKey()
{
$map = c\keyBy(fn ($v, $k) => $k . $v)(['a' => 'x']);
$this->assertSame(['ax' => 'x'], $map->toArray());
}
public function testKeyByAcceptsInternalFunction()
{
$this->assertSame(['A' => 'a'], c\keyBy(strtoupper(...))(['a'])->toArray());
}
public function testGroupByKeepsOrderAndUsesSeq()
{
$map = c\groupBy(fn ($v) => $v % 2)([1, 2, 3, 4]);
$this->assertEquals(new Map([1 => new Seq([1, 3]), 0 => new Seq([2, 4])]), $map);
$this->assertSame([1, 0], $map->keys()->toArray());
$this->assertInstanceOf(Seq::class, $map->get(1));
}
public function testToSeqDropsKeys()
{
$this->assertSame([1, 2], c\toSeq()(['a' => 1, 'b' => 2])->toArray());
}
public function testToMapPreservesGeneratorKeys()
{
$key = new \stdClass();
$map = c\toMap()((static function () use ($key) {
yield 'a' => 1;
yield 5 => 2;
yield $key => 3;
})());
$this->assertInstanceOf(Map::class, $map);
$this->assertSame(1, $map->get('a'));
$this->assertSame(2, $map->get(5));
$this->assertSame(3, $map->get($key));
}
public function testToSet()
{
$set = c\toSet()(['a' => 1, 'b' => 1, 'c' => 2]);
$this->assertInstanceOf(Set::class, $set);
$this->assertSame([1, 2], $set->toArray());
}
public function testToArrayPreservesKeys()
{
$this->assertSame(['a' => 1, 5 => 2], c\toArray()(self::gen(['a' => 1, 5 => 2])));
$this->assertSame(['a' => 1], c\toArray()(new Map(['a' => 1])));
$this->assertSame([1, 2], c\toArray()(new Seq([1, 2])));
}
public function testToArrayRejectsObjectKeys()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('toArray(): key of type stdClass cannot be an array key');
$map = new Map();
$map->put(new \stdClass(), 1);
c\toArray()($map);
}
public function testCollectorsAcceptLazyInput()
{
$map = c\keyBy(fn ($v) => $v * 10)(self::gen([1, 2]));
$this->assertSame([10 => 1, 20 => 2], $map->toArray());
$this->assertSame([0 => [1, 3], 1 => [2, 4]], c\groupBy(fn ($v) => $v % 2 ? 0 : 1)(self::gen([1, 2, 3, 4]))->map(fn ($k, $v) => $v->toArray())->toArray());
}
}

View File

@ -0,0 +1,214 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
class LazyTest extends \PHPUnit\Framework\TestCase
{
private static function gen(iterable $entries): \Generator
{
foreach ($entries as $key => $value) {
yield $key => $value;
}
}
private static function naturals(): \Generator
{
for ($i = 1; ; $i++) {
yield $i;
}
}
public function testLazyDoesNothingUntilConsumed()
{
$calls = 0;
$spy = static function ($value) use (&$calls) {
$calls++;
return $value;
};
$steps = [
lazy\map($spy), lazy\filter($spy), lazy\reject($spy), lazy\uniqueBy($spy), lazy\each($spy), lazy\tap($spy),
lazy\flatMap(static function ($value) use ($spy) { $spy($value); return [$value]; }),
];
foreach ($steps as $step) {
$result = $step(self::gen(['a' => 1, 'b' => 2]));
$this->assertInstanceOf(\Generator::class, $result);
}
$this->assertSame(0, $calls);
iterator_to_array(lazy\map($spy)(self::gen(['a' => 1, 'b' => 2])));
$this->assertSame(2, $calls);
}
public function testLazyChainSingleWalk()
{
$walked = 0;
$result = c\toArray()(lazy\take(3)(lazy\filter(static fn ($x) => $x % 2 === 0)(lazy\map(static function ($x) use (&$walked) {
$walked++;
return $x * 3;
})(self::naturals()))));
$this->assertSame([1 => 6, 3 => 12, 5 => 18], $result);
$this->assertSame(6, $walked);
}
public static function provideEagerEquivalents(): iterable
{
$double = static fn ($x) => $x * 2;
$isTwo = static fn ($x) => $x === 2;
$inner = static fn ($x, $k) => [$x, $k];
yield 'map' => [fn () => lazy\map($double), fn () => c\map($double)];
yield 'filter' => [fn () => lazy\filter($isTwo), fn () => c\filter($isTwo)];
yield 'filter null' => [fn () => lazy\filter(), fn () => c\filter()];
yield 'reject' => [fn () => lazy\reject($isTwo), fn () => c\reject($isTwo)];
yield 'flatMap' => [fn () => lazy\flatMap($inner), fn () => c\flatMap($inner)];
yield 'values' => [fn () => lazy\values(), fn () => c\values()];
yield 'keys' => [fn () => lazy\keys(), fn () => c\keys()];
yield 'unique' => [fn () => lazy\unique(), fn () => c\unique()];
yield 'uniqueBy' => [fn () => lazy\uniqueBy($double), fn () => c\uniqueBy($double)];
yield 'slice' => [fn () => lazy\slice(1, 1), fn () => c\slice(1, 1)];
yield 'slice open' => [fn () => lazy\slice(1), fn () => c\slice(1)];
yield 'slice neg' => [fn () => lazy\slice(-2, 1), fn () => c\slice(-2, 1)];
yield 'slice negend' => [fn () => lazy\slice(0, -1), fn () => c\slice(0, -1)];
yield 'take' => [fn () => lazy\take(2), fn () => c\take(2)];
yield 'skip' => [fn () => lazy\skip(2), fn () => c\skip(2)];
yield 'prepend' => [fn () => lazy\prepend(7, 8), fn () => c\prepend(7, 8)];
yield 'append' => [fn () => lazy\append(7, 8), fn () => c\append(7, 8)];
yield 'concat' => [fn () => lazy\concat(['x' => 9]), fn () => c\concat(['x' => 9])];
yield 'each' => [fn () => lazy\each($double), fn () => c\each($double)];
yield 'tap' => [fn () => lazy\tap($double), fn () => c\tap($double)];
}
#[\PHPUnit\Framework\Attributes\DataProvider('provideEagerEquivalents')]
public function testLazyMatchesEagerResults(\Closure $lazy, \Closure $eager)
{
$fixture = ['a' => 1, 'b' => 2, 'c' => 2];
$this->assertSame($eager()($fixture), iterator_to_array($lazy()($fixture)));
}
public function testLazyUniqueStreams()
{
$walked = 0;
$source = (static function () use (&$walked) {
foreach ([1, 1, 2, 2, 3, 4] as $value) {
$walked++;
yield $value;
}
})();
$generator = lazy\unique()($source);
$this->assertSame(0, $walked);
$this->assertSame(1, $generator->current());
$this->assertSame(1, $walked);
$generator->next();
$this->assertSame(2, $generator->current());
$this->assertSame(3, $walked);
}
public function testUniqueCanBeReusedAcrossRuns()
{
$step = lazy\unique();
$this->assertSame([1, 2], array_values(iterator_to_array($step([1, 1, 2]))));
$this->assertSame([1, 2], array_values(iterator_to_array($step([1, 2, 2]))));
}
public function testFlatMapRejectsNonIterable()
{
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('flatMap(): the callback must return an iterable, got int.');
iterator_to_array(lazy\flatMap(static fn ($x) => $x)([1]));
}
public function testTakeAndSkipRejectNegativeWhenBuilt()
{
try {
lazy\take(-1);
$this->fail('take(-1) should throw');
} catch (\InvalidArgumentException $e) {
$this->assertSame('take(): count must be >= 0, got -1', $e->getMessage());
}
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('skip(): count must be >= 0, got -1');
lazy\skip(-1);
}
public function testEachYieldsEverythingThroughWithKeys()
{
$seen = [];
$out = iterator_to_array(lazy\each(static function ($v, $k) use (&$seen) { $seen[] = [$k, $v]; })(self::gen(['a' => 1, 'b' => 2])));
$this->assertSame(['a' => 1, 'b' => 2], $out);
$this->assertSame([['a', 1], ['b', 2]], $seen);
}
public function testStepCanBeReusedAcrossInputs()
{
$step = lazy\map(static fn ($x) => $x + 1);
$this->assertSame([2, 3], iterator_to_array($step([1, 2])));
$this->assertSame([4], iterator_to_array($step([3])));
}
public function testChunkSplitsIntoListsOfTheSize()
{
$this->assertSame(
[[1, 2], [3, 4], [5]],
iterator_to_array(lazy\chunk(2)(self::gen(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]))),
);
}
public function testChunkPreservesKeysWhenAsked()
{
$this->assertSame(
[['a' => 1, 'b' => 2], ['c' => 3]],
iterator_to_array(lazy\chunk(2, preserveKeys: true)(self::gen(['a' => 1, 'b' => 2, 'c' => 3]))),
);
}
public function testChunkKeepsValuesUnderRepeatedKeys()
{
$repeated = (function () {
yield 'a' => 1;
yield 'a' => 2;
})();
$this->assertSame([[1, 2]], iterator_to_array(lazy\chunk(2)($repeated)));
}
public function testChunkYieldsEachChunkOnceFull()
{
$this->assertSame([1, 2, 3], lazy\chunk(3)(self::naturals())->current());
}
public function testChunkOfNothingYieldsNothing()
{
$this->assertSame([], iterator_to_array(lazy\chunk(2)([])));
}
public function testChunkRejectsSizeBelowOneWhenBuilt()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('chunk(): size must be >= 1, got 0');
lazy\chunk(0);
}
}

View File

@ -0,0 +1,123 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
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.
*/
class LibraryCallablesTest extends \PHPUnit\Framework\TestCase
{
// negated predicates
public function testFilterWithNegatedInternalFunction()
{
$this->assertSame([1 => 'x'], c\filter(f\not(is_numeric(...)))(['1', 'x']));
}
public function testFilterWithNegatedClosure()
{
$this->assertSame([1], c\filter(f\not(fn ($x) => $x > 1))([1, 2]));
}
public function testFilterWithNegatedPredicateMethod()
{
$this->assertSame([1], c\filter(p\gt(1)->not())([1, 2]));
}
public function testRejectWithNegatedPredicate()
{
$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]));
}
}

View File

@ -0,0 +1,255 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Ds\Map;
use Ds\Seq;
use Kadet\Functional as f;
use Kadet\Functional\Collections as c;
use Kadet\Functional\Exception\EmptyCollectionException;
class QueriesTest extends \PHPUnit\Framework\TestCase
{
public function testFirstReturnsNullOnEmpty()
{
$this->assertNull(c\first()([]));
$this->assertNull(c\first(fn ($v) => $v > 5)([1, 2]));
}
public function testFirstRequiredThrowsWithMessage()
{
$this->expectException(EmptyCollectionException::class);
$this->expectExceptionMessage('first(): no element matched');
c\first(fn ($v) => $v > 5, required: true)([1, 2]);
}
public function testFirstRequiredWithoutPredicateSaysEmpty()
{
$this->expectException(EmptyCollectionException::class);
$this->expectExceptionMessage('first(): collection is empty');
c\first(required: true)([]);
}
public function testFirstRequiredAcceptsNullMatch()
{
$this->assertNull(c\first(fn ($v) => $v === null, required: true)([1, null]));
}
public function testFirstFalsyValueFound()
{
$this->assertSame(0, c\first()([0]));
}
public function testFirstStopsEarly()
{
$gen = (static function () {
yield 1;
throw new \LogicException('consumed too far');
})();
$this->assertSame(1, c\first()($gen));
}
public function testFirstPassesKeyToPredicate()
{
$this->assertSame(2, c\first(fn ($v, $k) => $k === 'b')(['a' => 1, 'b' => 2]));
$this->assertSame(2, c\first(fn ($v, $k) => $k === 'b')(new Map(['a' => 1, 'b' => 2])));
}
public function testFindIsFirstWithPredicate()
{
$this->assertSame(2, c\find(fn ($v) => $v > 1)([1, 2, 3]));
$this->expectException(EmptyCollectionException::class);
$this->expectExceptionMessage('find(): no element matched');
c\find(fn ($v) => $v > 5, required: true)([1, 2, 3]);
}
public function testLastOnArrayAndPredicate()
{
$this->assertSame(3, c\last()([1, 2, 3]));
$this->assertSame(2, c\last(fn ($v) => $v < 3)([1, 2, 3]));
$this->assertNull(c\last()([]));
}
public function testLastOnGenerator()
{
$gen = (static function () {
yield 'a' => 1;
yield 'b' => 2;
})();
$this->assertSame(2, c\last()($gen));
}
public function testLastOnSeqAndSet()
{
$this->assertSame(3, c\last()(new Seq([1, 2, 3])));
$this->assertSame(2, c\last(fn ($v, $k) => $k < 2)(new Seq([1, 2, 3])));
$this->assertSame(3, c\last()(new \Ds\Set([1, 2, 3])));
$this->assertNull(c\last()(new Seq()));
}
public function testLastOnMapReturnsValue()
{
$this->assertSame('y', c\last()(new Map(['a' => 'x', 'b' => 'y'])));
$this->assertSame('x', c\last(fn ($v, $k) => $k === 'a')(new Map(['a' => 'x', 'b' => 'y'])));
}
public function testLastRequired()
{
$this->assertNull(c\last(fn ($v) => $v === null, required: true)([null, 1]));
$this->expectException(EmptyCollectionException::class);
$this->expectExceptionMessage('last(): collection is empty');
c\last(required: true)([]);
}
public function testLastRequiredNoMatchMessage()
{
$this->expectException(EmptyCollectionException::class);
$this->expectExceptionMessage('last(): no element matched');
c\last(fn ($v) => false, required: true)([1]);
}
public function testContainsIsStrict()
{
$this->assertFalse(c\contains('1')([1]));
$this->assertTrue(c\contains(1)([3, 1]));
$this->assertTrue(c\contains(null)([1, null]));
}
public function testContainsUsesDsKey()
{
$key = new class (1) implements \Ds\Key {
public function __construct(public int $id) {}
public function equals(mixed $obj): bool
{
return $obj->id === $this->id;
}
public function hash(): mixed
{
return $this->id;
}
};
$this->assertTrue(c\contains($key)([new $key(1)]));
$this->assertFalse(c\contains($key)([new $key(2)]));
}
public function testAnySomeEvery()
{
$this->assertTrue(c\any(fn ($v) => $v > 2)([1, 3]));
$this->assertFalse(c\any(fn ($v) => $v > 5)([1, 3]));
$this->assertFalse(c\any(fn ($v) => true)([]));
$this->assertTrue(c\some(fn ($v) => $v > 2)([1, 3]));
$this->assertTrue(c\every(fn ($v) => $v > 0)([1, 3]));
$this->assertFalse(c\every(fn ($v) => $v > 1)([1, 3]));
}
public function testEveryOnEmptyIsTrue()
{
$this->assertTrue(c\every(fn ($v) => false)([]));
}
public function testAnyStopsEarly()
{
$gen = (static function () {
yield 1;
throw new \LogicException('consumed too far');
})();
$this->assertTrue(c\any(fn ($v) => $v === 1)($gen));
}
public function testCountAndIsEmpty()
{
$gen = (static function () {
yield 1;
yield 2;
})();
$this->assertSame(2, c\count()($gen));
$this->assertSame(3, c\count()(new Seq([1, 2, 3])));
$this->assertSame(0, c\count()([]));
$this->assertTrue(c\isEmpty()([]));
$this->assertFalse(c\isEmpty()(new Seq([0])));
$this->assertTrue(c\isEmpty()(new Map()));
}
public function testMinMax()
{
$this->assertSame(1, c\min()([3, 1, 2]));
$this->assertSame(3, c\max()([3, 1, 2]));
$this->assertNull(c\min()([]));
$this->assertNull(c\max()([]));
}
public function testMinMaxTiesKeepFirstExtremum()
{
$a = (object) ['x' => 1];
$b = (object) ['x' => 1];
$this->assertSame($a, c\min()([$a, $b]));
$this->assertSame($a, c\max()([$a, $b]));
$this->assertSame(1, c\max()([1, 1.0]));
$this->assertSame(1.0, c\min()([1.0, 1]));
}
public function testMinWithSelector()
{
$this->assertSame(1, c\min(fn ($v) => strlen($v))(['ccc', 'a', 'bb']));
$this->assertSame(3, c\max(fn ($v) => strlen($v))(['ccc', 'a', 'bb']));
}
public function testReduce()
{
$this->assertSame(6, c\reduce(fn ($carry, $v) => $carry + $v, 0)([1, 2, 3]));
$this->assertSame('a:1,b:2,', c\reduce(fn ($carry, $v, $k) => "$carry$k:$v,", '')(['a' => 1, 'b' => 2]));
$this->assertNull(c\reduce(fn ($carry, $v) => $v)([]));
}
public function testReduceWithInternalFunctionGetsCarryAndValueOnly()
{
$this->assertSame(2, c\reduce(max(...), 0)([5 => 1, 9 => 2]));
}
public function testReduceWithDecoratedTwoParameterClosureGetsCarryAndValueOnly()
{
$calls = [];
$reducer = f\memoize(function ($carry, $v) use (&$calls) {
$calls[] = func_get_args();
return $carry + $v;
});
$this->assertSame(3, c\reduce($reducer, 0)(['a' => 1, 'b' => 2]));
$this->assertSame([[0, 1], [1, 2]], $calls);
}
public function testSum()
{
$this->assertSame(6, c\sum()([1, 2, 3]));
$this->assertSame(3.5, c\sum()([1, 2.5]));
$this->assertSame(4, c\sum(fn ($v) => strlen($v))(['ab', 'cd']));
}
public function testSumEmptyIsZero()
{
$this->assertSame(0, c\sum()([]));
}
public function testJoin()
{
$this->assertSame('a, b', c\join(', ')(['a', 'b']));
$this->assertSame('12', c\join()(new Seq([1, 2])));
$this->assertSame('', c\join(',')([]));
}
}

View File

@ -0,0 +1,590 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Ds\Map;
use Ds\Seq;
use Ds\Set;
use Kadet\Functional\Collections as c;
use function Kadet\Functional\partial;
use const Kadet\Functional\_;
class TransformsTest extends \PHPUnit\Framework\TestCase
{
private static function gen(iterable $entries): \Generator
{
foreach ($entries as $key => $value) {
yield $key => $value;
}
}
private function assertSeq(array $expected, $actual)
{
$this->assertInstanceOf(Seq::class, $actual);
$this->assertSame($expected, $actual->toArray());
}
private function assertMap(array $expected, $actual)
{
$this->assertInstanceOf(Map::class, $actual);
$this->assertSame($expected, $actual->toArray());
}
// map
public function testMapOnArrayPreservesKeys()
{
$this->assertSame(['a' => 2, 'b' => 4], c\map(fn ($v) => $v * 2)(['a' => 1, 'b' => 2]));
}
public function testMapOnSeq()
{
$this->assertSeq([2, 4], c\map(fn ($v) => $v * 2)(new Seq([1, 2])));
}
public function testMapOnMapPassesValueThenKey()
{
$this->assertEquals(new Map(['a' => 'a:1']), c\map(fn ($v, $k) => "$k:$v")(new Map(['a' => 1])));
}
public function testMapOnGeneratorGivesSeq()
{
$this->assertSeq([2, 4], c\map(fn ($v) => $v * 2)(self::gen([1, 2])));
}
public function testMapOnGeneratorPassesGeneratorKeys()
{
$this->assertSeq(['x:1', 'y:2'], c\map(fn ($v, $k) => "$k:$v")(self::gen(['x' => 1, 'y' => 2])));
}
public function testMapWithStaticMethodArrayCallable()
{
$this->assertSame(['a' => 2, 'b' => 4], c\map([TransformsTestHelper::class, 'double'])(['a' => 1, 'b' => 2]));
}
public function testMapOnSetFollowsSetMap()
{
$result = c\map(fn ($v) => $v % 2)(new Set([1, 2, 3]));
$this->assertInstanceOf(Set::class, $result);
$this->assertSame([1, 0], $result->toArray());
}
public function testMapWithInternalFunctionGetsValueOnly()
{
$this->assertSame(['a' => 'x'], c\map(trim(...))(['a' => ' x ']));
}
public function testTransformsWorkWithDecoratedCallback()
{
$this->assertSame(['aa'], c\map(partial(str_repeat(...), _, 2))(['a']));
}
// filter / reject
public function testFilterOnSeqReindexes()
{
$this->assertSame([1, 2], c\filter()(new Seq([0, 1, '', 2]))->toArray());
}
public function testFilterOnArrayPreservesKeys()
{
$this->assertSame([1 => 1, 3 => 2], c\filter()([0, 1, '', 2]));
}
public function testFilterOnMapPassesValueThenKey()
{
$this->assertMap(['b' => 2], c\filter(fn ($v, $k) => $k === 'b')(new Map(['a' => 1, 'b' => 2])));
}
public function testFilterOnGeneratorGivesSeq()
{
$this->assertSeq([2], c\filter(fn ($v) => $v % 2 === 0)(self::gen([1, 2, 3])));
}
public function testFilterWithByReferenceCallbackDoesNotWarn()
{
$warnings = [];
set_error_handler(function (int $level, string $message) use (&$warnings) {
$warnings[] = $message;
return true;
});
try {
$array = c\filter(fn (&$v) => $v > 1)([1, 2, 3]);
$seq = c\filter(fn (&$v) => $v > 1)(new Seq([1, 2, 3]));
$both = c\filter(fn (&$v, $k) => $k > 0)([1, 2, 3]);
} finally {
restore_error_handler();
}
$this->assertSame([], $warnings);
$this->assertSame([1 => 2, 2 => 3], $array);
$this->assertSeq([2, 3], $seq);
$this->assertSame([1 => 2, 2 => 3], $both);
}
public function testByReferenceCallbacksWriteBackOutsideNativeFilter()
{
// Where the callback is called with a variable, its changes to the value are kept, as in 1.0.0.
$trim = function (&$v) {
$v = trim($v);
return $v !== '';
};
$gen = fn () => self::gen([' a ', ' ', 'b ']);
$this->assertSame(['a', 'b'], iterator_to_array(\Kadet\Functional\Collections\Lazy\filter($trim)($gen()), false));
$this->assertSame([1 => ''], c\reject($trim)([' a ', ' ', 'b ']));
$this->assertSame('x', c\find($trim)(new Seq([' x '])));
$this->assertSame('a', c\first($trim)($gen()));
}
public function testFilterWithInternalPredicate()
{
$this->assertSame([1 => 'a'], c\filter(is_string(...))([1, 'a']));
}
public function testReject()
{
$odd = fn ($v) => $v % 2 === 1;
$this->assertSame([1 => 2], c\reject($odd)([1, 2, 3]));
$this->assertSeq([2], c\reject($odd)(new Seq([1, 2, 3])));
$this->assertMap(['b' => 2], c\reject($odd)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([2], c\reject($odd)(self::gen([1, 2, 3])));
}
// flatMap
public function testFlatMap()
{
$pair = fn ($v) => [$v, $v * 10];
$this->assertSame([1, 10, 2, 20], c\flatMap($pair)(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(new Seq([1, 2])));
$this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 10, 2, 20], c\flatMap($pair)(self::gen([1, 2])));
}
public function testFlatMapFlattensOneLevelOnly()
{
$this->assertSame([[1], 2], c\flatMap(fn ($v) => $v)([[[1]], [2]]));
}
public function testFlatMapAcceptsIterableResults()
{
$this->assertSame(['a', 'b', 'c'], c\flatMap(fn ($v) => new Seq(str_split($v)))(['ab', 'c']));
}
// values / keys
public function testValues()
{
$this->assertSame([1, 2], c\values()(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2], c\values()(new Seq([1, 2])));
$this->assertSeq([1, 2], c\values()(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2], c\values()(self::gen(['a' => 1, 'b' => 2])));
}
public function testKeys()
{
$this->assertSame(['a', 'b'], c\keys()(['a' => 1, 'b' => 2]));
$this->assertSeq([0, 1], c\keys()(new Seq([5, 6])));
$this->assertSeq(['a', 'b'], c\keys()(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq(['a', 'b'], c\keys()(self::gen(['a' => 1, 'b' => 2])));
}
// unique / uniqueBy
public function testUnique()
{
$this->assertSame([0 => 1, 1 => '1', 3 => 2], c\unique()([1, '1', 1, 2]));
$this->assertSeq([1, '1', 2], c\unique()(new Seq([1, '1', 1, 2])));
$this->assertMap(['a' => 1, 'c' => 2], c\unique()(new Map(['a' => 1, 'b' => 1, 'c' => 2])));
$this->assertSeq([1, 2], c\unique()(self::gen([1, 2, 2, 1])));
}
public function testUniqueComparesObjectsByIdentity()
{
$a = new \stdClass();
$b = new \stdClass();
$this->assertSame([$a, $b], array_values(c\unique()([$a, $b, $a])));
}
public function testUniqueComparesFloatsAndArraysStrictly()
{
$this->assertSame([0 => 1.0, 1 => 1, 2 => [1], 4 => ['1']], c\unique()([1.0, 1, [1], 1.0, ['1'], [1]]));
}
public function testUniqueByKeepsFirst()
{
$items = [['id' => 1, 'n' => 'a'], ['id' => 1, 'n' => 'b'], ['id' => 2, 'n' => 'c']];
$this->assertSame(['a', 'c'], array_column(c\uniqueBy(fn ($o) => $o['id'])($items), 'n'));
$this->assertSame([0, 2], array_keys(c\uniqueBy(fn ($o) => $o['id'])($items)));
$this->assertSame(
['a', 'c'],
c\uniqueBy(fn ($o) => $o['id'])(new Seq($items))->map(fn ($o) => $o['n'])->toArray(),
);
$this->assertSame(['x', 'z'], c\uniqueBy(fn ($o) => $o['id'])(new Map(['x' => $items[0], 'y' => $items[1], 'z' => $items[2]]))->keys()->toArray());
$this->assertCount(2, c\uniqueBy(fn ($o) => $o['id'])(self::gen($items)));
}
public function testUniqueByKeepsTemporaryObjectsDistinct()
{
$this->assertSame([1, 2, 3], c\uniqueBy(fn ($v) => new \stdClass())([1, 2, 3]));
}
// sort / sortBy
public function testSort()
{
$this->assertSame([1 => 1, 2 => 2, 0 => 3], c\sort()([3, 1, 2]));
$this->assertSeq([1, 2, 3], c\sort()(new Seq([3, 1, 2])));
$this->assertMap(['b' => 1, 'a' => 3], c\sort()(new Map(['a' => 3, 'b' => 1])));
$this->assertSeq([1, 2, 3], c\sort()(self::gen([3, 1, 2])));
}
public function testSortWithComparator()
{
$this->assertSame([3, 2, 1], array_values(c\sort(fn ($a, $b) => $b <=> $a)([1, 3, 2])));
}
public function testSortWithInternalComparatorGetsBothArguments()
{
$this->assertSame(['a', 'b', 'c'], array_values(c\sort(strcmp(...))(['c', 'a', 'b'])));
}
public function testSortIsStable()
{
$items = [['k' => 1, 'n' => 'a'], ['k' => 0, 'n' => 'b'], ['k' => 1, 'n' => 'c'], ['k' => 0, 'n' => 'd']];
$this->assertSame(['b', 'd', 'a', 'c'], array_column(c\sort(fn ($a, $b) => $a['k'] <=> $b['k'])($items), 'n'));
$this->assertSame(['a', 'c', 'b', 'd'], array_column(c\sortBy(fn ($o) => $o['k'], descending: true)($items), 'n'));
}
public function testSortBy()
{
$len = fn ($v) => strlen($v);
$this->assertSame([1 => 'a', 2 => 'bb', 0 => 'ccc'], c\sortBy($len)(['ccc', 'a', 'bb']));
$this->assertSame([0 => 'ccc', 2 => 'bb', 1 => 'a'], c\sortBy($len, true)(['ccc', 'a', 'bb']));
$this->assertSeq(['a', 'bb', 'ccc'], c\sortBy($len)(new Seq(['ccc', 'a', 'bb'])));
$this->assertMap(['y' => 'a', 'x' => 'ccc'], c\sortBy($len)(new Map(['x' => 'ccc', 'y' => 'a'])));
$this->assertSeq(['a', 'bb', 'ccc'], c\sortBy($len)(self::gen(['ccc', 'a', 'bb'])));
}
public function testSortByPassesKey()
{
$this->assertSame(['b' => 1, 'a' => 2], c\sortBy(fn ($v, $k) => $k, true)(['a' => 2, 'b' => 1]));
}
public function testSortByIsStableForTies()
{
$items = [['k' => 1, 'n' => 'a'], ['k' => 0, 'n' => 'b'], ['k' => 1, 'n' => 'c'], ['k' => 0, 'n' => 'd'], ['k' => 1, 'n' => 'e']];
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'])($items), 'n'));
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'])(new Seq($items))->toArray(), 'n'));
}
public function testSortByDescendingIsStableForTies()
{
$items = [['k' => 0, 'n' => 'a'], ['k' => 1, 'n' => 'b'], ['k' => 0, 'n' => 'c'], ['k' => 1, 'n' => 'd'], ['k' => 0, 'n' => 'e']];
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'], true)($items), 'n'));
$this->assertSame(['b', 'd', 'a', 'c', 'e'], array_column(c\sortBy(fn ($o) => $o['k'], true)(new Seq($items))->toArray(), 'n'));
}
public function testSortByWithDateTimeKeys()
{
$at = fn (string $time) => new \DateTimeImmutable("2026-01-01 $time");
$items = ['c' => $at('10:00'), 'a' => $at('08:00'), 'd' => $at('10:00:01'), 'b' => $at('09:30')];
$this->assertSame(['a', 'b', 'c', 'd'], array_keys(c\sortBy(fn ($v) => $v)($items)));
$this->assertSame(['d', 'c', 'b', 'a'], array_keys(c\sortBy(fn ($v) => $v, true)($items)));
}
public function testSortByMixedNumericKeys()
{
$values = [3, 1.5, -2, 2.0, 0, 1, -0.5];
$this->assertSame([-2, -0.5, 0, 1, 1.5, 2.0, 3], c\sortBy(fn ($v) => $v)(new Seq($values))->toArray());
$this->assertSame([3, 2.0, 1.5, 1, 0, -0.5, -2], array_values(c\sortBy(fn ($v) => $v, true)($values)));
}
public function testSortAndSortByCompareLikeSpaceship()
{
$keys = [10, '9', 'abc', null, 2.5, '10', true, 'b', '', 0, [1, 2], [1], '1e1', ' 9', false];
$ascending = $keys;
usort($ascending, fn ($a, $b) => $a <=> $b);
$descending = $keys;
usort($descending, fn ($a, $b) => $b <=> $a);
$this->assertSame($ascending, array_values(c\sortBy(fn ($v) => $v)($keys)));
$this->assertSame($descending, array_values(c\sortBy(fn ($v) => $v, true)($keys)));
$this->assertSame($ascending, array_values(c\sort()($keys)));
}
public function testSortByKeepsObjectKeysOfMap()
{
$a = new \stdClass();
$b = new \stdClass();
$map = new Map();
$map->put($a, 2);
$map->put($b, 1);
$sorted = c\sortBy(fn ($v) => $v)($map);
$this->assertInstanceOf(Map::class, $sorted);
$this->assertSame([$b, $a], $sorted->keys()->toArray());
$this->assertSame([1, 2], $sorted->values()->toArray());
}
public function testSortWithComparatorKeepsObjectKeysOfMap()
{
$a = new \stdClass();
$b = new \stdClass();
$map = new Map();
$map->put($a, 1);
$map->put($b, 2);
$sorted = c\sort(fn ($x, $y) => $y <=> $x)($map);
$this->assertInstanceOf(Map::class, $sorted);
$this->assertSame([$b, $a], $sorted->keys()->toArray());
$this->assertSame([2, 1], $sorted->values()->toArray());
}
public function testSortByDoesNotMutateInput()
{
$array = ['x' => 3, 'y' => 1, 'z' => 2];
$seq = new Seq([3, 1, 2]);
c\sortBy(fn ($v) => $v)($array);
c\sortBy(fn ($v) => $v, true)($seq);
c\sort()($array);
$this->assertSame(['x' => 3, 'y' => 1, 'z' => 2], $array);
$this->assertSame([3, 1, 2], $seq->toArray());
}
public function testSortDoesNotMutateInput()
{
$seq = new Seq([3, 1, 2]);
$map = new Map(['a' => 3, 'b' => 1]);
c\sortBy(fn ($v) => $v)($seq);
c\sort()($seq);
c\sort()($map);
$this->assertSame([3, 1, 2], $seq->toArray());
$this->assertSame(['a' => 3, 'b' => 1], $map->toArray());
}
// slice / take / skip
public function testSlice()
{
$this->assertSame(['b' => 2, 'c' => 3], c\slice(1, 2)(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]));
$this->assertSeq([2, 3], c\slice(1, 2)(new Seq([1, 2, 3, 4])));
$this->assertMap(['b' => 2, 'c' => 3], c\slice(1, 2)(new Map(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4])));
$this->assertSeq([2, 3], c\slice(1, 2)(self::gen([1, 2, 3, 4])));
}
public function testSliceWithNegativeArguments()
{
$this->assertSame([2 => 3, 3 => 4], c\slice(-2)([1, 2, 3, 4]));
$this->assertSame([1 => 2], c\slice(1, -2)([1, 2, 3, 4]));
$this->assertSeq([3], c\slice(-2, -1)(self::gen([1, 2, 3, 4])));
}
public function testTake()
{
$this->assertSame(['a' => 1], c\take(1)(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2], c\take(2)(new Seq([1, 2, 3])));
$this->assertMap(['a' => 1], c\take(1)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([], c\take(0)(new Seq([1, 2, 3])));
}
public function testTakeStopsConsumingGenerator()
{
$infinite = (function () {
for ($i = 1; ; $i++) {
yield $i;
}
})();
$this->assertSeq([1, 2, 3], c\take(3)($infinite));
}
public function testChunk()
{
$this->assertSame([[1, 2], [3]], c\chunk(2)(['a' => 1, 'b' => 2, 'c' => 3]));
$this->assertSame([['a' => 1, 'b' => 2], ['c' => 3]], c\chunk(2, preserveKeys: true)(['a' => 1, 'b' => 2, 'c' => 3]));
$this->assertSeq([[1, 2], [3]], c\chunk(2)(new Seq([1, 2, 3])));
$this->assertSeq([['a' => 1], ['b' => 2]], c\chunk(1, preserveKeys: true)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([[1, 2], [3]], c\chunk(2)(self::gen([1, 2, 3])));
}
public function testChunkRejectsSizeBelowOne()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('chunk(): size must be >= 1, got 0');
c\chunk(0);
}
public function testSkip()
{
$this->assertSame(['b' => 2], c\skip(1)(['a' => 1, 'b' => 2]));
$this->assertSeq([3], c\skip(2)(new Seq([1, 2, 3])));
$this->assertMap(['b' => 2], c\skip(1)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([2, 3], c\skip(1)(self::gen([1, 2, 3])));
}
public function testTakeRejectsNegativeCount()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('take(): count must be >= 0, got -1');
c\take(-1);
}
public function testSkipRejectsNegativeCount()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('skip(): count must be >= 0, got -1');
c\skip(-1);
}
// prepend / append / concat / merge
public function testPrepend()
{
$this->assertSame([0, 1, 1, 2], c\prepend(0, 1)(['a' => 1, 'b' => 2]));
$this->assertSeq([0, 1, 2], c\prepend(0)(new Seq([1, 2])));
$this->assertSeq([0, 1, 2], c\prepend(0)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([0, 1, 2], c\prepend(0)(self::gen([1, 2])));
}
public function testAppend()
{
$this->assertSame([1, 2, 3, 4], c\append(3, 4)(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2, 3], c\append(3)(new Seq([1, 2])));
$this->assertSeq([1, 2, 3], c\append(3)(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2, 3], c\append(3)(self::gen([1, 2])));
}
public function testConcat()
{
$this->assertSame([1, 2, 3, 4], c\concat(['x' => 3, 'a' => 4])(['a' => 1, 'b' => 2]));
$this->assertSeq([1, 2, 3], c\concat(self::gen([3]))(new Seq([1, 2])));
$this->assertSeq([1, 2, 3], c\concat([3])(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2, 3], c\concat(new Seq([3]))(self::gen([1, 2])));
}
public function testListOperationsOnSetGiveSet()
{
$result = c\append(2, 3)(new Set([1, 2]));
$this->assertInstanceOf(Set::class, $result);
$this->assertSame([1, 2, 3], $result->toArray());
}
public function testMerge()
{
$this->assertSame(['a' => 1, 'b' => 3, 'c' => 4], c\merge(['b' => 3, 'c' => 4])(['a' => 1, 'b' => 2]));
$this->assertSame([0 => 'x', 1 => 'b', 5 => 'c'], c\merge([0 => 'x', 5 => 'c'])(['a', 'b']));
$this->assertSeq([1, 2, 3], c\merge([3])(new Seq([1, 2])));
$this->assertMap(['a' => 1, 'b' => 3, 'c' => 4], c\merge(new Map(['b' => 3, 'c' => 4]))(new Map(['a' => 1, 'b' => 2])));
$this->assertSeq([1, 2, 3], c\merge([3])(self::gen([1, 2])));
}
public function testMergeObjectKeyIntoArrayThrows()
{
$map = new Map();
$map->put(new \stdClass(), 1);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('merge(): key of type stdClass cannot be an array key');
c\merge($map)(['a' => 1]);
}
// each / tap
public function testEachDoesNotReplaceValues()
{
$calls = 0;
$seq = new Seq([1, 2, 3]);
$result = c\each(function ($v) use (&$calls) { $calls++; return 'x'; })($seq);
$this->assertSame([1, 2, 3], $result->toArray());
$this->assertSame([1, 2, 3], $seq->toArray());
$this->assertSame(3, $calls);
}
public function testEachPassesValueThenKey()
{
$seen = [];
$fn = function ($v, $k) use (&$seen) { $seen[] = "$k:$v"; };
$this->assertSame(['a' => 1], c\each($fn)(['a' => 1]));
$map = new Map(['b' => 2]);
$this->assertSame($map, c\each($fn)($map));
$this->assertSeq([3], c\each($fn)(self::gen(['c' => 3])));
$this->assertSame(['a:1', 'b:2', 'c:3'], $seen);
}
public function testTapIsEach()
{
$seen = [];
$this->assertSame([1, 2], c\tap(function ($v) use (&$seen) { $seen[] = $v; })([1, 2]));
$this->assertSame([1, 2], $seen);
}
// object keys (Review Focus 3)
public function testMapWithObjectKeysKeepsKeys()
{
$k1 = new \stdClass();
$k2 = new \stdClass();
$map = new Map();
$map->put($k1, 2);
$map->put($k2, 1);
foreach ([
c\map(fn ($v) => $v * 10),
c\filter(),
c\sortBy(fn ($v) => $v),
c\uniqueBy(fn ($v, $k) => $k),
] as $transform) {
$result = $transform($map);
$this->assertInstanceOf(Map::class, $result);
$this->assertTrue($result->hasKey($k1));
$this->assertTrue($result->hasKey($k2));
}
}
}
class TransformsTestHelper
{
public static function double(int $value): int
{
return $value * 2;
}
}

344
tests/CompileTest.php Normal file
View File

@ -0,0 +1,344 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Kadet\Functional as f;
use Kadet\Functional\Compilable;
use Kadet\Functional\Decorator;
use Kadet\Functional\Predicate\ClosurePredicate;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Utils\FunctionPipeline;
use Kadet\Functional\Utils\PartiallyAppliedFunction;
use Kadet\Functional\Utils\UnpackedArgs;
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 testCompileDropsExtraArgumentsForInternalTarget()
{
$this->assertSame('aa', f\compile(f\partial(str_repeat(...), _, 2))('a', 'extra'));
}
public function testCompileKeepsExtraArgumentsForVariadicInternalTarget()
{
$this->assertSame(['a', 'b', 'c'], f\compile(f\partial(array_merge(...), ['a']))(['b'], ['c']));
}
public function testCompileDropsExtraArgumentsForCurriedInternalTarget()
{
$this->assertSame('aa', f\compile(f\curry(str_repeat(...), null, 'a'))(2, 'extra'));
}
public function testCompileSinglePlaceholderOfInternalTarget()
{
$leading = f\partial(str_pad(...), _, 3, '0', STR_PAD_LEFT);
$middle = f\partial(substr(...), 'abcdef', _, 2);
$this->assertSame('007', f\compile($leading)('7', 'extra'));
$this->assertSame('cd', f\compile($middle)(2, 'extra'));
$this->assertSame($this->outcome(fn () => $leading()), $this->outcome(fn () => f\compile($leading)()));
$this->assertSame($this->outcome(fn () => $middle()), $this->outcome(fn () => f\compile($middle)()));
}
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');
$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));
}
public function testPipelineAndPredicatesAreCompilable()
{
$this->assertInstanceOf(Compilable::class, f\pipe('strtoupper'));
$this->assertInstanceOf(Compilable::class, f\predicate(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\not(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\all(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\any(fn ($x) => $x));
$this->assertInstanceOf(Compilable::class, f\always());
}
public function testCompiledPipelineDoesNotInvokePipeline()
{
$spy = new class(trim(...), strtoupper(...)) extends FunctionPipeline {
public int $calls = 0;
public function __invoke(...$args)
{
$this->calls++;
return parent::__invoke(...$args);
}
};
$compiled = $spy->compile();
$this->assertSame('A', $compiled(' a '));
$this->assertSame(0, $spy->calls);
}
public function testCompiledPipelineMatchesPipeline()
{
$pipelines = [
f\pipe(),
f\pipe(fn (...$args) => $args),
f\pipe(trim(...), strtoupper(...)),
f\pipe(fn ($a, $b) => $a + $b, fn ($x) => $x * 2, fn ($x) => $x - 1),
f\pipe(fn ($a, $b) => new UnpackedArgs([$b, $a]), fn ($a, $b) => "$a$b"),
];
foreach ($pipelines as $pipeline) {
$this->assertEquals($pipeline(1, 2), f\compile($pipeline)(1, 2));
}
}
public function testCompiledPipelineKeepsCurrying()
{
$pipeline = f\pipe(strtoupper(...), f\curry(fn ($a, $b) => "$a$b"));
$this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b'));
}
public function testCompiledClosureOfCurriedKeepsCurrying()
{
$curried = f\curry(fn ($a, $b) => "$a$b");
$this->assertSame('ab', f\compile($curried(...))('a')('b'));
$this->assertSame('ab', f\compile($curried(...))('a', 'b'));
}
public function testCompiledPipelineOfCurriedClosureKeepsCurrying()
{
$curried = f\curry(fn ($a, $b) => "$a$b");
$pipeline = f\pipe(strtoupper(...), $curried(...));
$this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b'));
$this->assertSame('ab', f\compile(f\pipe($curried(...), fn ($rest) => $rest('b')))('a'));
}
public function testCompileUnwrapsClosureOfCompilable()
{
$spy = new class(strtoupper(...)) extends FunctionPipeline {
public int $calls = 0;
public function __invoke(...$args)
{
$this->calls++;
return parent::__invoke(...$args);
}
};
$this->assertSame('A', f\compile(\Closure::fromCallable($spy))('a'));
$this->assertSame('A', f\compile($spy(...))('a'));
$this->assertSame(0, $spy->calls);
}
public function testCompiledPredicatesMatchPredicates()
{
$values = [0, 1, 2, 3, '1', 'x'];
$cases = [
[f\predicate(fn ($x) => $x), $values],
[f\predicate(is_numeric(...)), $values],
[p\gt(1), $values],
[p\gt(1)->not(), $values],
[f\not(fn ($x) => $x > 1), $values],
[f\all(p\gt(0), p\lt(3)), $values],
[f\any(p\lt(1), p\gt(2)), $values],
[f\always(), $values],
[f\never(), $values],
[p\property('x', p\gt(1)), [(object) ['x' => 1], (object) ['x' => 2]]],
];
foreach ($cases as [$predicate, $inputs]) {
$compiled = f\compile($predicate);
foreach ($inputs as $value) {
$this->assertSame($predicate($value), $compiled($value));
}
}
}
public function testCompiledPredicateDoesNotInvokePredicate()
{
$inner = new class(fn ($x) => $x > 1) extends ClosurePredicate {
public int $calls = 0;
public function __invoke(...$args): bool
{
$this->calls++;
return parent::__invoke(...$args);
}
};
$compiled = f\compile(f\all($inner->not(), p\lt(5)));
$this->assertTrue($compiled(1));
$this->assertFalse($compiled(2));
$this->assertSame(0, $inner->calls);
}
public function testUnaryCompiledCallablesMatchWithExtraArguments()
{
$callables = [
f\pipe(fn ($x) => $x, strtoupper(...)),
f\predicate(fn ($x) => $x === 'a'),
f\not(fn ($x) => $x === 'a'),
f\all(fn ($x) => $x === 'a', p\same('a')),
f\any(fn ($x) => $x === 'b', p\same('a')),
];
foreach ($callables as $callable) {
$this->assertSame($callable('a', 'extra'), f\compile($callable)('a', 'extra'));
}
}
public function testCompiledEmptyCombinatorsTakeNoArguments()
{
$this->assertSame(f\all()(), f\compile(f\all())());
$this->assertSame(f\any()(), f\compile(f\any())());
}
}

View File

@ -21,9 +21,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
};
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testNeverAndPredicate(...$args)
{
$predicate = ConstantPredicate::never()->and($this->getTestPredicate());
@ -31,9 +29,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertFalse($predicate(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testNeverOrPredicate(...$args)
{
$predicate = predicate($this->getTestPredicate());
@ -42,9 +38,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertSame($predicate(...$args), $tested(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testAlwaysOrPredicate(...$args)
{
$predicate = ConstantPredicate::always()->or($this->getTestPredicate());
@ -52,9 +46,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertTrue($predicate(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testAlwaysAndPredicate(...$args)
{
$predicate = predicate($this->getTestPredicate());
@ -63,9 +55,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertSame($predicate(...$args), $tested(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testNever(...$args)
{
$tested = ConstantPredicate::never();
@ -73,9 +63,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertFalse($tested(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testNotAlways(...$args)
{
$tested = ConstantPredicate::always()->not();
@ -83,9 +71,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertFalse($tested(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testNotNever(...$args)
{
$tested = ConstantPredicate::never()->not();
@ -93,9 +79,7 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
$this->assertTrue($tested(...$args));
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testAlways(...$args)
{
$tested = ConstantPredicate::always();

View File

@ -19,7 +19,7 @@ class CurriedFunctionTest extends \PHPUnit\Framework\TestCase
private $variadic;
private $optional;
protected function setUp()
protected function setUp(): void
{
$this->function = function ($a, $b, $c, $d) {
return $a . $b . $c . $d;

View File

@ -14,12 +14,9 @@ class FunctionPipelineTest extends \PHPUnit\Framework\TestCase
/** @var \PHPUnit\Framework\MockObject\MockObject */
private $mock;
protected function setUp()/* The :void return type declaration that should be here would cause a BC issue */
protected function setUp(): void
{
$this->mock = $this
->getMockBuilder('stdClass')
->setMethods(['a', 'b', 'c'])
->getMock();
$this->mock = $this->createMock(PipelineSteps::class);
}
public function testPipelineCalling()
@ -93,3 +90,10 @@ class FunctionPipelineTest extends \PHPUnit\Framework\TestCase
$this->assertSame('d', $second('a'));
}
}
class PipelineSteps
{
public function a() {}
public function b() {}
public function c() {}
}

View File

@ -14,12 +14,9 @@ class MemoizedFunctionTest extends \PHPUnit\Framework\TestCase
/** @var \PHPUnit\Framework\MockObject\MockObject */
private $mock;
protected function setUp()/* The :void return type declaration that should be here would cause a BC issue */
protected function setUp(): void
{
$this->mock = $this
->getMockBuilder('stdClass')
->setMethods(['memoized'])
->getMock();
$this->mock = $this->createMock(MemoizedTarget::class);
}
public function testCalledOnlyOnce()
@ -37,14 +34,23 @@ class MemoizedFunctionTest extends \PHPUnit\Framework\TestCase
{
$memoized = f\memoize([$this->mock, 'memoized']);
$this->mock->expects($this->exactly(2))->method('memoized')->withConsecutive(
[ 'a' ],
[ [] ]
$calls = [];
$this->mock->expects($this->exactly(2))->method('memoized')->willReturnCallback(
function (...$args) use (&$calls) {
$calls[] = $args;
}
);
for ($i = 0; $i < 10; $i++) {
$memoized('a');
$memoized([]);
}
$this->assertSame([['a'], [[]]], $calls);
}
}
class MemoizedTarget
{
public function memoized() {}
}

76
tests/NoDs/NoDsTest.php Normal file
View File

@ -0,0 +1,76 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
use Kadet\Functional\Collections as c;
use Kadet\Functional\Exception\DsUnavailableException;
/**
* Runs without php-ds, see phpunit.no-ds.xml.
*/
class NoDsTest extends \PHPUnit\Framework\TestCase
{
public function testDsIsHidden()
{
$this->assertFalse(class_exists(\Ds\Seq::class));
}
public function testTransformsOnArraysWork()
{
$this->assertSame(['a' => 2, 'b' => 4], c\map(fn ($v) => $v * 2)(['a' => 1, 'b' => 2]));
$this->assertSame(['b' => 2], c\filter(fn ($v) => $v > 1)(['a' => 1, 'b' => 2]));
$this->assertSame([1, 2], c\values()(['a' => 1, 'b' => 2]));
$this->assertSame([1, 1, 2, 2], c\flatMap(fn ($v) => [$v, $v])([1, 2]));
}
public function testEagerTransformOnGeneratorGivesList()
{
$gen = static function () {
yield 'a' => 1;
yield 'b' => 2;
};
$this->assertSame([1, 2], c\map(fn ($v) => $v)($gen()));
$this->assertSame([2], c\filter(fn ($v) => $v > 1)($gen()));
$this->assertSame([1, 2], c\values()($gen()));
$this->assertSame([1, 1, 2, 2], c\flatMap(fn ($v) => [$v, $v])($gen()));
}
public function testToSeqThrowsNamedException()
{
$this->expectException(DsUnavailableException::class);
$this->expectExceptionMessage('toSeq() needs php-ds: install ext-ds or require php-ds/php-ds');
c\toSeq();
}
public function testGroupByThrows()
{
$this->expectException(DsUnavailableException::class);
$this->expectExceptionMessage('groupBy()');
c\groupBy(fn ($v) => $v);
}
public function testOtherDsCollectorsThrow()
{
foreach (['toMap' => fn () => c\toMap(), 'toSet' => fn () => c\toSet(), 'keyBy' => fn () => c\keyBy(fn ($v) => $v)] as $name => $build) {
try {
$build();
$this->fail("$name() should throw");
} catch (DsUnavailableException $e) {
$this->assertStringContainsString("$name()", $e->getMessage());
}
}
}
public function testToArrayWorks()
{
$this->assertSame(['a' => 1], c\toArray()(['a' => 1]));
}
}

View File

@ -39,9 +39,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
{
use RandomDataTrait;
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testFieldTransform($data = null)
{
$object = new PropertiesObject($data, null, []);
@ -50,9 +48,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame($transform($object), $data);
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testFieldTransformMagic($data = null)
{
$object = new PropertiesObject(false, false, ['magic' => $data]);
@ -61,9 +57,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame($transform($object), $data);
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testPropertyTransformGetter($data = null)
{
$object = new PropertiesObject(false, $data, []);
@ -72,9 +66,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame($transform($object), $data);
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testGetterTransform($data = null)
{
$object = new PropertiesObject(false, $data, []);
@ -83,9 +75,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame($transform($object), $data);
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testPropertyTransformField($data = null)
{
$object = new PropertiesObject($data, false, []);
@ -94,9 +84,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame($transform($object), $data);
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testPropertyTransformMagic($data = null)
{
$object = new PropertiesObject(false, false, ['magic' => $data]);
@ -105,9 +93,7 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
$this->assertSame($transform($object), $data);
}
/**
* @dataProvider randomDataProvider
*/
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
public function testMethodTransform($data = null)
{
$object = new PropertiesObject(false, false);

89
tests/PipeSyntaxTest.php Normal file
View File

@ -0,0 +1,89 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/*
* The only test written with the pipe operator, which needs PHP 8.5 to parse. phpunit.xml loads this file only on
* PHP 8.5 and newer, so the rest of the suite still runs on PHP 8.2-8.4.
*/
use Ds\Map;
use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
use Kadet\Functional\Exception\EmptyCollectionException;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Transforms as t;
use PHPUnit\Framework\Attributes\RequiresPhp;
final class PipeDeparture
{
public function __construct(
public readonly string $id,
public readonly string $line,
public readonly string $destination,
public readonly int $delay,
) {
}
}
#[RequiresPhp('>= 8.5')]
class PipeSyntaxTest extends \PHPUnit\Framework\TestCase
{
/** @return list<PipeDeparture> */
private static function departures(): array
{
return [
new PipeDeparture('d1', '8', 'Stogi', 0),
new PipeDeparture('d2', '8', 'Stogi', 120),
new PipeDeparture('d3', '12', 'Oliwa', 30),
new PipeDeparture('d4', '12', 'Oliwa', 300),
new PipeDeparture('d5', '6', 'Łostowice', 0),
];
}
public function testRealisticPipeline()
{
$late = self::departures()
|> lazy\filter(p\property('delay', p\gt(60)))
|> c\keyBy(t\property('id'))
|> c\map(fn (PipeDeparture $departure) => "{$departure->line} → {$departure->destination}");
$this->assertInstanceOf(Map::class, $late);
$this->assertSame(['d2' => '8 → Stogi', 'd4' => '12 → Oliwa'], $late->toArray());
}
public function testFirstRequired()
{
$mostDelayed = self::departures()
|> c\sortBy(t\property('delay'), descending: true)
|> c\first(required: true);
$this->assertSame('d4', $mostDelayed->id);
}
public function testFirstRequiredThrowsWhenNothingMatches()
{
$this->expectException(EmptyCollectionException::class);
$this->expectExceptionMessage('first(): no element matched');
self::departures() |> c\first(p\property('line', p\same('2')), required: true);
}
public function testLazyChainReadsOnlyWhatIsNeeded()
{
$read = [];
$line = self::departures()
|> lazy\tap(function (PipeDeparture $departure) use (&$read) { $read[] = $departure->id; })
|> lazy\map(t\property('line'))
|> c\find(fn (string $line) => $line === '12');
$this->assertSame('12', $line);
$this->assertSame(['d1', 'd2', 'd3'], $read);
}
}

View File

@ -9,7 +9,7 @@
trait RandomDataTrait
{
public function randomDataProvider()
public static function randomDataProvider()
{
return [
['x'],

View File

@ -28,10 +28,20 @@ class TestObject
{
return ref([$this, 'priv']);
}
public static function twice($x)
{
return $x * 2;
}
}
class ReferencingTest extends \PHPUnit\Framework\TestCase
{
public function testReferenceStaticMethodArrayCallable()
{
$this->assertSame(4, ref([TestObject::class, 'twice'])(2));
}
public function testReferenceClosure()
{
$f = function () { };
@ -53,11 +63,9 @@ class ReferencingTest extends \PHPUnit\Framework\TestCase
$this->assertSame('foo', $reference());
}
/**
* @expectedException \Kadet\Functional\Exception\NoAccessException
*/
public function testReferencingPrivateFunctionsFromOutsideOfObject()
{
$this->expectException(\Kadet\Functional\Exception\NoAccessException::class);
$object = new TestObject();
ref([$object, 'priv']);

View File

@ -20,7 +20,7 @@ class ReflectionPartialAppliedFunctionTest extends \PHPUnit\Framework\TestCase
*/
private $function;
protected function setUp()
protected function setUp(): void
{
$this->function = f\partial(function (array $a, array $b, array $c, array $d = ['d']) {
return array_merge($a, $b, $c, $d);

18
tests/bootstrap-no-ds.php Normal file
View File

@ -0,0 +1,18 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
$loader = require __DIR__.'/../vendor/autoload.php';
if (extension_loaded('ds')) {
echo "Skipping the no-php-ds suite: ext-ds is loaded.\n";
exit(0);
}
// Hide the php-ds polyfill, so the library runs as if php-ds was not installed.
$loader->setPsr4('Ds\\', []);

139
tests/types/collections.php Normal file
View File

@ -0,0 +1,139 @@
<?php
/**
* Copyright 2026 Kacper Donat
*
* @author Kacper "Kadet" Donat <kacper@kadet.net>
*
* Full license available in separate LICENSE file
*/
/**
* PHPStan type tests for the collection functions.
*
* This file is never executed: `composer phpstan` analyses it, and every `assertType()` that doesn't match the
* inferred type is reported as an error. `Ds\Seq` is not generic in PHPStan's view (it comes from the php-ds 2.0
* polyfill), so `Ds\Seq<T>` is shown as `Ds\Seq&iterable<T>`.
*/
use Kadet\Functional\Collections as c;
use Kadet\Functional\Collections\Lazy as lazy;
use Kadet\Functional\Predicates as p;
use Kadet\Functional\Transforms as t;
use function PHPStan\Testing\assertType;
final class Foo
{
public function __construct(public int $id, public string $name)
{
}
}
/**
* @param \Ds\Seq<int> $ints
* @param \Closure(int): string $format
* @param \Ds\Seq<Foo> $foos
* @param \Ds\Map<string, Foo> $map
* @param \Ds\Set<Foo> $set
* @param list<Foo> $list
* @param \Generator<int, Foo> $generator
*/
function transforms(
\Ds\Seq $ints,
\Closure $format,
\Ds\Seq $foos,
\Ds\Map $map,
\Ds\Set $set,
array $list,
\Generator $generator,
): void {
// The container is preserved, the element type comes from the callback.
assertType('Ds\Seq&iterable<string>', c\map($format)($ints));
assertType('Ds\Seq&iterable<string>', c\map(fn (int $v): string => $format($v))($ints));
assertType('Ds\Seq&iterable<Foo>', c\filter(fn (Foo $foo): bool => $foo->id > 0)($foos));
assertType('Ds\Map<string, string>', c\map(fn (Foo $foo, string $key): string => $key)($map));
assertType('Ds\Set<int>', c\map(fn (Foo $foo): int => $foo->id)($set));
assertType('array<int|string, Foo>', c\reject(fn (Foo $foo): bool => $foo->id > 0)($list));
assertType('Ds\Seq&iterable<Foo>', c\sortBy(fn (Foo $foo): string => $foo->name)($foos));
// A generator can't be returned half-consumed, so eager transforms give a Seq (or a list without php-ds).
assertType('(Ds\Seq&iterable<int>)|list<int>', c\map(fn (Foo $foo): int => $foo->id)($generator));
assertType('(Ds\Seq&iterable<Foo>)|list<Foo>', c\tap(fn (Foo $foo) => null)($generator));
// List transforms: a Map gives a Seq.
assertType('Ds\Seq&iterable<string>', c\flatMap(fn (Foo $foo): array => [$foo->name])($map));
assertType('list<string>', c\flatMap(fn (Foo $foo): array => [$foo->name])($list));
// Transforms without a callback keep the element types, but not the container.
assertType('iterable<int, Foo>', c\values()($foos));
assertType('iterable<int<0, max>, Foo>', c\take(2)($list));
assertType('iterable<int, array<int<0, max>, Foo>>', c\chunk(2)($list));
// Lazy transforms give generators.
assertType('Generator<mixed, string, mixed, mixed>', lazy\map(fn (Foo $foo): string => $foo->name)($list));
assertType('Generator<mixed, Foo, mixed, mixed>', lazy\filter(fn (Foo $foo): bool => $foo->id > 0)($list));
assertType('Generator<int, array<int<0, max>, Foo>, mixed, mixed>', lazy\chunk(2)($list));
}
/**
* @param \Ds\Seq<Foo> $foos
* @param list<Foo> $list
*/
function collectors(\Ds\Seq $foos, array $list): void
{
assertType('Ds\Map<int, Foo>', c\keyBy(fn (Foo $foo): int => $foo->id)($foos));
assertType('Ds\Map<string, Ds\Seq&iterable<Foo>>', c\groupBy(fn (Foo $foo): string => $foo->name)($list));
assertType('Ds\Set<Foo>', c\toSet()($list));
assertType('array<int<0, max>, Foo>', c\toArray()($list));
}
/**
* @param \Ds\Seq<Foo> $foos
* @param \Ds\Map<string, Foo> $map
* @param list<int> $ints
*/
function queries(\Ds\Seq $foos, \Ds\Map $map, array $ints): void
{
assertType('Foo|null', c\first()($foos));
assertType('Foo|null', c\first(fn (Foo $foo): bool => $foo->id > 1)($foos));
assertType('Foo|null', c\last()($foos));
assertType('Foo|null', c\find(fn (Foo $foo): bool => $foo->id > 1)($map));
assertType('int|null', c\min()($ints));
assertType('string|null', c\max(fn (Foo $foo): string => $foo->name)($foos));
assertType('bool', c\any(fn (Foo $foo): bool => $foo->id > 1)($foos));
assertType('int', c\count()($foos));
assertType('int', c\reduce(fn (int $carry, Foo $foo): int => $carry + $foo->id, 0)($foos));
}
/**
* Accessors (`t\`) and predicates (`p\`) are untyped about the object they read, so the element type is `object` or
* `mixed`; what matters is that these idioms analyse without errors.
*
* @param list<Foo> $list
*/
function accessors(array $list): void
{
assertType('array<int|string, mixed>', c\map(t\property('name'))($list));
assertType('array<int|string, object>', c\sortBy(t\property('id'))($list));
assertType('object|null', $list |> c\sortBy(t\property('id'), descending: true) |> c\first());
assertType('float|int', c\sum(t\property('id'))($list));
assertType('mixed', c\max(t\property('id'))($list));
assertType('Ds\\Map<mixed, object>', c\keyBy(t\property('id'))($list));
assertType('array<int|string, mixed>', c\filter(p\property('id', p\gt(1)))($list));
assertType('mixed', $list |> c\first(p\property('name', p\same('a'))));
}
/**
* The pipe operator: PHPStan infers through `|>` like through a call.
*
* @param \Ds\Seq<Foo> $foos
*/
function pipes(\Ds\Seq $foos): void
{
assertType('Ds\Seq&iterable<string>', $foos |> c\map(fn (Foo $foo): string => $foo->name));
assertType(
'Foo|null',
$foos |> c\filter(fn (Foo $foo): bool => $foo->id > 1) |> c\first(),
);
assertType('Ds\Map<int, Foo>', $foos |> lazy\filter(fn (Foo $foo): bool => $foo->id > 1) |> c\keyBy(fn (Foo $foo): int => $foo->id));
}