Compare commits
No commits in common. "6cb6a22f188286811b47c9456e53326ab3a56acd" and "12918eb3cb701b03792fea5ea479f13d0652a075" have entirely different histories.
6cb6a22f18
...
12918eb3cb
11
.gitattributes
vendored
11
.gitattributes
vendored
@ -1,11 +0,0 @@
|
|||||||
# 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
1
.gitignore
vendored
@ -6,4 +6,3 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
|
|
||||||
test.*.php
|
test.*.php
|
||||||
.phpunit.cache
|
|
||||||
|
|||||||
40
CHANGELOG.md
40
CHANGELOG.md
@ -1,40 +0,0 @@
|
|||||||
# 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
21
LICENSE
@ -1,21 +0,0 @@
|
|||||||
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
483
README.md
@ -1,483 +0,0 @@
|
|||||||
# 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.
|
|
||||||
8
bin/psysh
Normal file
8
bin/psysh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/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());
|
||||||
@ -10,12 +10,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2"
|
"php": ">=7.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.5",
|
"phpunit/phpunit": "^7.0",
|
||||||
"php-ds/php-ds": "^2.0",
|
"psy/psysh": "@stable"
|
||||||
"phpstan/phpstan": "^2.1"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@ -23,27 +22,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"./src/functions.php",
|
"./src/functions.php",
|
||||||
"./src/Internal/functions.php",
|
"./src/Predicates/index.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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2070
composer.lock
generated
2070
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,679 +0,0 @@
|
|||||||
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
28
phpstan.neon
@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
<?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>
|
|
||||||
16
phpunit.xml
16
phpunit.xml
@ -1,17 +1,11 @@
|
|||||||
<?xml version="1.0"?>
|
<phpunit bootstrap="tests/bootstrap.php">
|
||||||
<phpunit bootstrap="tests/bootstrap.php" cacheDirectory=".phpunit.cache">
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="all">
|
<testsuite name="all">
|
||||||
<directory>tests</directory>
|
<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>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites><filter>
|
||||||
<source>
|
<whitelist processUncoveredFilesFromWhitelist="true">
|
||||||
<include>
|
|
||||||
<directory suffix=".php">src</directory>
|
<directory suffix=".php">src</directory>
|
||||||
</include>
|
</whitelist>
|
||||||
</source>
|
</filter>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@ -1,232 +0,0 @@
|
|||||||
<?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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
<?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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,393 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
<?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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,355 +0,0 @@
|
|||||||
<?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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,658 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@ -10,13 +10,9 @@
|
|||||||
namespace Kadet\Functional\Predicate;
|
namespace Kadet\Functional\Predicate;
|
||||||
|
|
||||||
|
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
use Kadet\Functional\Predicate;
|
use Kadet\Functional\Predicate;
|
||||||
|
|
||||||
use function Kadet\Functional\Internal\callback;
|
class AllOfPredicate extends AbstractPredicate
|
||||||
use function Kadet\Functional\Internal\isUnary;
|
|
||||||
|
|
||||||
class AllOfPredicate extends AbstractPredicate implements Compilable
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Array of predicates to check if all are satisfied.
|
* Array of predicates to check if all are satisfied.
|
||||||
@ -45,35 +41,4 @@ class AllOfPredicate extends AbstractPredicate implements Compilable
|
|||||||
|
|
||||||
return true;
|
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -10,13 +10,9 @@
|
|||||||
namespace Kadet\Functional\Predicate;
|
namespace Kadet\Functional\Predicate;
|
||||||
|
|
||||||
|
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
use Kadet\Functional\Predicate;
|
use Kadet\Functional\Predicate;
|
||||||
|
|
||||||
use function Kadet\Functional\Internal\callback;
|
class AnyOfPredicate extends AbstractPredicate
|
||||||
use function Kadet\Functional\Internal\isUnary;
|
|
||||||
|
|
||||||
class AnyOfPredicate extends AbstractPredicate implements Compilable
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Array of predicates to check if all are satisfied.
|
* Array of predicates to check if all are satisfied.
|
||||||
@ -46,35 +42,4 @@ class AnyOfPredicate extends AbstractPredicate implements Compilable
|
|||||||
|
|
||||||
return false;
|
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -10,13 +10,9 @@
|
|||||||
namespace Kadet\Functional\Predicate;
|
namespace Kadet\Functional\Predicate;
|
||||||
|
|
||||||
|
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
use Kadet\Functional\Decorator;
|
use Kadet\Functional\Decorator;
|
||||||
|
|
||||||
use function Kadet\Functional\compile;
|
class ClosurePredicate extends AbstractPredicate implements Decorator
|
||||||
use function Kadet\Functional\Internal\isUnary;
|
|
||||||
|
|
||||||
class ClosurePredicate extends AbstractPredicate implements Decorator, Compilable
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var \Closure
|
* @var \Closure
|
||||||
@ -42,21 +38,4 @@ class ClosurePredicate extends AbstractPredicate implements Decorator, Compilabl
|
|||||||
{
|
{
|
||||||
return $this->closure;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -11,10 +11,9 @@ namespace Kadet\Functional\Predicate;
|
|||||||
|
|
||||||
|
|
||||||
use function Kadet\Functional\{ all, any };
|
use function Kadet\Functional\{ all, any };
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
use Kadet\Functional\Predicate;
|
use Kadet\Functional\Predicate;
|
||||||
|
|
||||||
class ConstantPredicate extends AbstractPredicate implements Compilable
|
class ConstantPredicate extends AbstractPredicate
|
||||||
{
|
{
|
||||||
/** @var boolean */
|
/** @var boolean */
|
||||||
private $value;
|
private $value;
|
||||||
@ -59,13 +58,6 @@ class ConstantPredicate extends AbstractPredicate implements Compilable
|
|||||||
return $this->value;
|
return $this->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function compile(): \Closure
|
|
||||||
{
|
|
||||||
$value = $this->value;
|
|
||||||
|
|
||||||
return static fn (mixed ...$args): bool => $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function always()
|
public static function always()
|
||||||
{
|
{
|
||||||
static $always;
|
static $always;
|
||||||
|
|||||||
@ -10,14 +10,10 @@
|
|||||||
namespace Kadet\Functional\Predicate;
|
namespace Kadet\Functional\Predicate;
|
||||||
|
|
||||||
|
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
use Kadet\Functional\Decorator;
|
use Kadet\Functional\Decorator;
|
||||||
use Kadet\Functional\Predicate;
|
use Kadet\Functional\Predicate;
|
||||||
|
|
||||||
use function Kadet\Functional\compile;
|
class NegatedPredicate extends AbstractPredicate implements Decorator
|
||||||
use function Kadet\Functional\Internal\isUnary;
|
|
||||||
|
|
||||||
class NegatedPredicate extends AbstractPredicate implements Decorator, Compilable
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Predicate to be negated.
|
* Predicate to be negated.
|
||||||
@ -43,15 +39,6 @@ class NegatedPredicate extends AbstractPredicate implements Decorator, Compilabl
|
|||||||
|
|
||||||
public function getDecorated()
|
public function getDecorated()
|
||||||
{
|
{
|
||||||
return $this->negated;
|
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,7 +19,7 @@ use function Kadet\Functional\predicate;
|
|||||||
*/
|
*/
|
||||||
function equals($expected): Predicate
|
function equals($expected): Predicate
|
||||||
{
|
{
|
||||||
return predicate(function ($value) use ($expected): bool {
|
return predicate(function ($value) use ($expected) {
|
||||||
return $expected == $value;
|
return $expected == $value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -31,7 +31,7 @@ function equals($expected): Predicate
|
|||||||
*/
|
*/
|
||||||
function same($expected): Predicate
|
function same($expected): Predicate
|
||||||
{
|
{
|
||||||
return predicate(function ($value) use ($expected): bool {
|
return predicate(function ($value) use ($expected) {
|
||||||
return $value === $expected;
|
return $value === $expected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -43,7 +43,7 @@ function same($expected): Predicate
|
|||||||
*/
|
*/
|
||||||
function gt($expected): Predicate
|
function gt($expected): Predicate
|
||||||
{
|
{
|
||||||
return predicate(function ($value) use ($expected): bool {
|
return predicate(function ($value) use ($expected) {
|
||||||
return $value > $expected;
|
return $value > $expected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ function gt($expected): Predicate
|
|||||||
*/
|
*/
|
||||||
function ge($expected): Predicate
|
function ge($expected): Predicate
|
||||||
{
|
{
|
||||||
return predicate(function ($value) use ($expected): bool {
|
return predicate(function ($value) use ($expected) {
|
||||||
return $value >= $expected;
|
return $value >= $expected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -67,7 +67,7 @@ function ge($expected): Predicate
|
|||||||
*/
|
*/
|
||||||
function lt($expected): Predicate
|
function lt($expected): Predicate
|
||||||
{
|
{
|
||||||
return predicate(function ($value) use ($expected): bool {
|
return predicate(function ($value) use ($expected) {
|
||||||
return $value < $expected;
|
return $value < $expected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -79,7 +79,7 @@ function lt($expected): Predicate
|
|||||||
*/
|
*/
|
||||||
function le($expected): Predicate
|
function le($expected): Predicate
|
||||||
{
|
{
|
||||||
return predicate(function ($value) use ($expected): bool {
|
return predicate(function ($value) use ($expected) {
|
||||||
return $value <= $expected;
|
return $value <= $expected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,12 +49,9 @@ function field(string $field, $predicate)
|
|||||||
return predicate(f\pipe(t\field($field), $predicate));
|
return predicate(f\pipe(t\field($field), $predicate));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return \Kadet\Functional\Predicate
|
|
||||||
*/
|
|
||||||
function instance(string $class)
|
function instance(string $class)
|
||||||
{
|
{
|
||||||
return new ClosurePredicate(function ($value) use ($class): bool {
|
return new ClosurePredicate(function ($value) use ($class) {
|
||||||
return $value instanceof $class;
|
return $value instanceof $class;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -9,17 +9,11 @@
|
|||||||
|
|
||||||
namespace Kadet\Functional\Transforms;
|
namespace Kadet\Functional\Transforms;
|
||||||
|
|
||||||
/**
|
|
||||||
* @return \Closure(object): mixed
|
|
||||||
*/
|
|
||||||
function getter($property)
|
function getter($property)
|
||||||
{
|
{
|
||||||
return method('get'.ucfirst($property));
|
return method('get'.ucfirst($property));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return \Closure(object): mixed
|
|
||||||
*/
|
|
||||||
function property($property)
|
function property($property)
|
||||||
{
|
{
|
||||||
return function ($object) use ($property) {
|
return function ($object) use ($property) {
|
||||||
@ -31,9 +25,6 @@ function property($property)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return \Closure(object): mixed
|
|
||||||
*/
|
|
||||||
function field($field)
|
function field($field)
|
||||||
{
|
{
|
||||||
return function ($object) use ($field) {
|
return function ($object) use ($field) {
|
||||||
@ -41,9 +32,6 @@ function field($field)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return \Closure(object): mixed
|
|
||||||
*/
|
|
||||||
function method($method, ...$args)
|
function method($method, ...$args)
|
||||||
{
|
{
|
||||||
return function ($object) use ($method, $args) {
|
return function ($object) use ($method, $args) {
|
||||||
|
|||||||
@ -11,13 +11,9 @@ namespace Kadet\Functional\Utils;
|
|||||||
|
|
||||||
|
|
||||||
use function Kadet\Functional\apply;
|
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 Kadet\Functional\Decorator;
|
||||||
use function Kadet\Functional\reflect;
|
|
||||||
|
|
||||||
class FixedFunction implements Decorator, Compilable
|
class FixedFunction implements Decorator
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var callable
|
* @var callable
|
||||||
@ -43,36 +39,4 @@ class FixedFunction implements Decorator, Compilable
|
|||||||
{
|
{
|
||||||
return $this->fixed;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -9,12 +9,8 @@
|
|||||||
|
|
||||||
namespace Kadet\Functional\Utils;
|
namespace Kadet\Functional\Utils;
|
||||||
|
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
|
|
||||||
use function Kadet\Functional\compile;
|
class FunctionPipeline
|
||||||
use function Kadet\Functional\Internal\isUnary;
|
|
||||||
|
|
||||||
class FunctionPipeline implements Compilable
|
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var callable[]
|
* @var callable[]
|
||||||
@ -24,23 +20,13 @@ class FunctionPipeline implements Compilable
|
|||||||
/**
|
/**
|
||||||
* FunctionPipeline constructor.
|
* FunctionPipeline constructor.
|
||||||
*
|
*
|
||||||
* @param callable ...$functions
|
* @param callable[] $functions
|
||||||
*/
|
*/
|
||||||
public function __construct(callable ...$functions)
|
public function __construct(callable ...$functions)
|
||||||
{
|
{
|
||||||
$this->functions = $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)
|
public function append(callable $callable)
|
||||||
{
|
{
|
||||||
return new static(...array_merge($this->functions, [ $callable ]));
|
return new static(...array_merge($this->functions, [ $callable ]));
|
||||||
@ -59,49 +45,4 @@ class FunctionPipeline implements Compilable
|
|||||||
: $callable($arg);
|
: $callable($arg);
|
||||||
}, new UnpackedArgs($args));
|
}, 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -10,13 +10,11 @@
|
|||||||
namespace Kadet\Functional\Utils;
|
namespace Kadet\Functional\Utils;
|
||||||
|
|
||||||
use const Kadet\Functional\_ as PLACEHOLDER;
|
use const Kadet\Functional\_ as PLACEHOLDER;
|
||||||
use Kadet\Functional\Compilable;
|
|
||||||
use Kadet\Functional\Decorator;
|
use Kadet\Functional\Decorator;
|
||||||
use function Kadet\Functional\compile;
|
|
||||||
use function Kadet\Functional\not;
|
use function Kadet\Functional\not;
|
||||||
use function Kadet\Functional\Predicates\same;
|
use function Kadet\Functional\Predicates\same;
|
||||||
|
|
||||||
class PartiallyAppliedFunction implements Decorator, Compilable
|
class PartiallyAppliedFunction implements Decorator
|
||||||
{
|
{
|
||||||
protected $callable;
|
protected $callable;
|
||||||
protected $arguments = [];
|
protected $arguments = [];
|
||||||
@ -55,7 +53,10 @@ class PartiallyAppliedFunction implements Decorator, Compilable
|
|||||||
: $argument;
|
: $argument;
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($result, array_values($args));
|
return array_merge(
|
||||||
|
array_values($result),
|
||||||
|
array_values($args)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDecorated()
|
public function getDecorated()
|
||||||
@ -63,55 +64,6 @@ class PartiallyAppliedFunction implements Decorator, Compilable
|
|||||||
return $this->callable;
|
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)
|
public static function fix(callable $callable, ...$args)
|
||||||
{
|
{
|
||||||
$applied = new self($callable, ...$args);
|
$applied = new self($callable, ...$args);
|
||||||
|
|||||||
@ -15,8 +15,6 @@ use Kadet\Functional\Utils\FunctionPipeline;
|
|||||||
use Kadet\Functional\Utils\MemoizedFunction;
|
use Kadet\Functional\Utils\MemoizedFunction;
|
||||||
use Kadet\Functional\Utils\PartiallyAppliedFunction;
|
use Kadet\Functional\Utils\PartiallyAppliedFunction;
|
||||||
|
|
||||||
use function Kadet\Functional\Internal\applyWithArity;
|
|
||||||
|
|
||||||
function partial(callable $callable, ...$args): PartiallyAppliedFunction
|
function partial(callable $callable, ...$args): PartiallyAppliedFunction
|
||||||
{
|
{
|
||||||
return new PartiallyAppliedFunction($callable, ...$args);
|
return new PartiallyAppliedFunction($callable, ...$args);
|
||||||
@ -26,15 +24,23 @@ function apply(callable $callable, ...$args)
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$reflection = reflect($callable);
|
$reflection = reflect($callable);
|
||||||
|
$required = $reflection->getNumberOfRequiredParameters();
|
||||||
|
$all = $reflection->getNumberOfParameters();
|
||||||
|
|
||||||
return applyWithArity(
|
switch (true) {
|
||||||
$callable,
|
case $reflection->isVariadic():
|
||||||
$callable,
|
return partial($callable, ...$args);
|
||||||
$reflection->isVariadic(),
|
|
||||||
$reflection->getNumberOfRequiredParameters(),
|
case count($args) >= $all:
|
||||||
$reflection->getNumberOfParameters(),
|
$applied = $callable(...array_slice($args, 0, $all));
|
||||||
$args
|
return is_callable($applied) ? apply($applied, ...array_slice($args, $all)) : $applied;
|
||||||
);
|
|
||||||
|
case count($args) >= $required:
|
||||||
|
return $callable(...$args);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return partial($callable, ...$args);
|
||||||
|
}
|
||||||
} catch (\ReflectionException $exception) {
|
} catch (\ReflectionException $exception) {
|
||||||
// if cannot gather necessary information for intelligent application just use normal one
|
// if cannot gather necessary information for intelligent application just use normal one
|
||||||
return $callable(...$args);
|
return $callable(...$args);
|
||||||
@ -66,25 +72,3 @@ function memoize(callable $callable)
|
|||||||
{
|
{
|
||||||
return new MemoizedFunction($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);
|
|
||||||
}
|
|
||||||
@ -41,14 +41,13 @@ function closure($callable)
|
|||||||
|
|
||||||
case is_array($callable):
|
case is_array($callable):
|
||||||
list($object, $method) = $callable;
|
list($object, $method) = $callable;
|
||||||
// A static method ([Foo::class, 'method']) is given by its class name, not an object.
|
$reflection = new \ReflectionObject($object);
|
||||||
$class = is_string($object) ? $object : get_class($object);
|
$method = $reflection->getMethod($method);
|
||||||
$method = new \ReflectionMethod($object, $method);
|
|
||||||
|
|
||||||
if (!$method->isPublic()) {
|
if (!$method->isPublic()) {
|
||||||
$backtrace = debug_backtrace();
|
$backtrace = debug_backtrace();
|
||||||
foreach ($backtrace as $trace) {
|
foreach ($backtrace as $trace) {
|
||||||
if (isset($trace['class']) && $trace['class'] === $class) {
|
if (isset($trace['class']) && $trace['class'] === $reflection->name) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +58,7 @@ function closure($callable)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $method->getClosure(is_string($object) ? null : $object);
|
return $method->getClosure($object);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return \Closure::fromCallable($callable);
|
return \Closure::fromCallable($callable);
|
||||||
@ -103,13 +102,13 @@ function unpack($arguments)
|
|||||||
function all(...$predicates)
|
function all(...$predicates)
|
||||||
{
|
{
|
||||||
$predicates = unpack($predicates);
|
$predicates = unpack($predicates);
|
||||||
return new AllOfPredicate(...array_map(predicate(...), $predicates));
|
return new AllOfPredicate(...array_map(predicate::class, $predicates));
|
||||||
}
|
}
|
||||||
|
|
||||||
function any(...$predicates)
|
function any(...$predicates)
|
||||||
{
|
{
|
||||||
$predicates = unpack($predicates);
|
$predicates = unpack($predicates);
|
||||||
return new AnyOfPredicate(...array_map(predicate(...), $predicates));
|
return new AnyOfPredicate(...array_map(predicate::class, $predicates));
|
||||||
}
|
}
|
||||||
|
|
||||||
function symbol($name = null)
|
function symbol($name = null)
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
<?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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,255 +0,0 @@
|
|||||||
<?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(',')([]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,590 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,344 +0,0 @@
|
|||||||
<?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())());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,7 +21,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testNeverAndPredicate(...$args)
|
public function testNeverAndPredicate(...$args)
|
||||||
{
|
{
|
||||||
$predicate = ConstantPredicate::never()->and($this->getTestPredicate());
|
$predicate = ConstantPredicate::never()->and($this->getTestPredicate());
|
||||||
@ -29,7 +31,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertFalse($predicate(...$args));
|
$this->assertFalse($predicate(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testNeverOrPredicate(...$args)
|
public function testNeverOrPredicate(...$args)
|
||||||
{
|
{
|
||||||
$predicate = predicate($this->getTestPredicate());
|
$predicate = predicate($this->getTestPredicate());
|
||||||
@ -38,7 +42,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($predicate(...$args), $tested(...$args));
|
$this->assertSame($predicate(...$args), $tested(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testAlwaysOrPredicate(...$args)
|
public function testAlwaysOrPredicate(...$args)
|
||||||
{
|
{
|
||||||
$predicate = ConstantPredicate::always()->or($this->getTestPredicate());
|
$predicate = ConstantPredicate::always()->or($this->getTestPredicate());
|
||||||
@ -46,7 +52,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertTrue($predicate(...$args));
|
$this->assertTrue($predicate(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testAlwaysAndPredicate(...$args)
|
public function testAlwaysAndPredicate(...$args)
|
||||||
{
|
{
|
||||||
$predicate = predicate($this->getTestPredicate());
|
$predicate = predicate($this->getTestPredicate());
|
||||||
@ -55,7 +63,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($predicate(...$args), $tested(...$args));
|
$this->assertSame($predicate(...$args), $tested(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testNever(...$args)
|
public function testNever(...$args)
|
||||||
{
|
{
|
||||||
$tested = ConstantPredicate::never();
|
$tested = ConstantPredicate::never();
|
||||||
@ -63,7 +73,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertFalse($tested(...$args));
|
$this->assertFalse($tested(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testNotAlways(...$args)
|
public function testNotAlways(...$args)
|
||||||
{
|
{
|
||||||
$tested = ConstantPredicate::always()->not();
|
$tested = ConstantPredicate::always()->not();
|
||||||
@ -71,7 +83,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertFalse($tested(...$args));
|
$this->assertFalse($tested(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testNotNever(...$args)
|
public function testNotNever(...$args)
|
||||||
{
|
{
|
||||||
$tested = ConstantPredicate::never()->not();
|
$tested = ConstantPredicate::never()->not();
|
||||||
@ -79,7 +93,9 @@ class ConstantPredicateTest extends PHPUnit\Framework\TestCase
|
|||||||
$this->assertTrue($tested(...$args));
|
$this->assertTrue($tested(...$args));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testAlways(...$args)
|
public function testAlways(...$args)
|
||||||
{
|
{
|
||||||
$tested = ConstantPredicate::always();
|
$tested = ConstantPredicate::always();
|
||||||
|
|||||||
@ -19,7 +19,7 @@ class CurriedFunctionTest extends \PHPUnit\Framework\TestCase
|
|||||||
private $variadic;
|
private $variadic;
|
||||||
private $optional;
|
private $optional;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp()
|
||||||
{
|
{
|
||||||
$this->function = function ($a, $b, $c, $d) {
|
$this->function = function ($a, $b, $c, $d) {
|
||||||
return $a . $b . $c . $d;
|
return $a . $b . $c . $d;
|
||||||
|
|||||||
@ -14,9 +14,12 @@ class FunctionPipelineTest extends \PHPUnit\Framework\TestCase
|
|||||||
/** @var \PHPUnit\Framework\MockObject\MockObject */
|
/** @var \PHPUnit\Framework\MockObject\MockObject */
|
||||||
private $mock;
|
private $mock;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp()/* The :void return type declaration that should be here would cause a BC issue */
|
||||||
{
|
{
|
||||||
$this->mock = $this->createMock(PipelineSteps::class);
|
$this->mock = $this
|
||||||
|
->getMockBuilder('stdClass')
|
||||||
|
->setMethods(['a', 'b', 'c'])
|
||||||
|
->getMock();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPipelineCalling()
|
public function testPipelineCalling()
|
||||||
@ -90,10 +93,3 @@ class FunctionPipelineTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame('d', $second('a'));
|
$this->assertSame('d', $second('a'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PipelineSteps
|
|
||||||
{
|
|
||||||
public function a() {}
|
|
||||||
public function b() {}
|
|
||||||
public function c() {}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -14,9 +14,12 @@ class MemoizedFunctionTest extends \PHPUnit\Framework\TestCase
|
|||||||
/** @var \PHPUnit\Framework\MockObject\MockObject */
|
/** @var \PHPUnit\Framework\MockObject\MockObject */
|
||||||
private $mock;
|
private $mock;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp()/* The :void return type declaration that should be here would cause a BC issue */
|
||||||
{
|
{
|
||||||
$this->mock = $this->createMock(MemoizedTarget::class);
|
$this->mock = $this
|
||||||
|
->getMockBuilder('stdClass')
|
||||||
|
->setMethods(['memoized'])
|
||||||
|
->getMock();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCalledOnlyOnce()
|
public function testCalledOnlyOnce()
|
||||||
@ -34,23 +37,14 @@ class MemoizedFunctionTest extends \PHPUnit\Framework\TestCase
|
|||||||
{
|
{
|
||||||
$memoized = f\memoize([$this->mock, 'memoized']);
|
$memoized = f\memoize([$this->mock, 'memoized']);
|
||||||
|
|
||||||
$calls = [];
|
$this->mock->expects($this->exactly(2))->method('memoized')->withConsecutive(
|
||||||
$this->mock->expects($this->exactly(2))->method('memoized')->willReturnCallback(
|
[ 'a' ],
|
||||||
function (...$args) use (&$calls) {
|
[ [] ]
|
||||||
$calls[] = $args;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for ($i = 0; $i < 10; $i++) {
|
for ($i = 0; $i < 10; $i++) {
|
||||||
$memoized('a');
|
$memoized('a');
|
||||||
$memoized([]);
|
$memoized([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertSame([['a'], [[]]], $calls);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoizedTarget
|
|
||||||
{
|
|
||||||
public function memoized() {}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
<?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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -39,7 +39,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
{
|
{
|
||||||
use RandomDataTrait;
|
use RandomDataTrait;
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testFieldTransform($data = null)
|
public function testFieldTransform($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject($data, null, []);
|
$object = new PropertiesObject($data, null, []);
|
||||||
@ -48,7 +50,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($transform($object), $data);
|
$this->assertSame($transform($object), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testFieldTransformMagic($data = null)
|
public function testFieldTransformMagic($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject(false, false, ['magic' => $data]);
|
$object = new PropertiesObject(false, false, ['magic' => $data]);
|
||||||
@ -57,7 +61,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($transform($object), $data);
|
$this->assertSame($transform($object), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testPropertyTransformGetter($data = null)
|
public function testPropertyTransformGetter($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject(false, $data, []);
|
$object = new PropertiesObject(false, $data, []);
|
||||||
@ -66,7 +72,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($transform($object), $data);
|
$this->assertSame($transform($object), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testGetterTransform($data = null)
|
public function testGetterTransform($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject(false, $data, []);
|
$object = new PropertiesObject(false, $data, []);
|
||||||
@ -75,7 +83,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($transform($object), $data);
|
$this->assertSame($transform($object), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testPropertyTransformField($data = null)
|
public function testPropertyTransformField($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject($data, false, []);
|
$object = new PropertiesObject($data, false, []);
|
||||||
@ -84,7 +94,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($transform($object), $data);
|
$this->assertSame($transform($object), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testPropertyTransformMagic($data = null)
|
public function testPropertyTransformMagic($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject(false, false, ['magic' => $data]);
|
$object = new PropertiesObject(false, false, ['magic' => $data]);
|
||||||
@ -93,7 +105,9 @@ class ObjectTransformsTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame($transform($object), $data);
|
$this->assertSame($transform($object), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider('randomDataProvider')]
|
/**
|
||||||
|
* @dataProvider randomDataProvider
|
||||||
|
*/
|
||||||
public function testMethodTransform($data = null)
|
public function testMethodTransform($data = null)
|
||||||
{
|
{
|
||||||
$object = new PropertiesObject(false, false);
|
$object = new PropertiesObject(false, false);
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
trait RandomDataTrait
|
trait RandomDataTrait
|
||||||
{
|
{
|
||||||
public static function randomDataProvider()
|
public function randomDataProvider()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['x'],
|
['x'],
|
||||||
|
|||||||
@ -28,20 +28,10 @@ class TestObject
|
|||||||
{
|
{
|
||||||
return ref([$this, 'priv']);
|
return ref([$this, 'priv']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function twice($x)
|
|
||||||
{
|
|
||||||
return $x * 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReferencingTest extends \PHPUnit\Framework\TestCase
|
class ReferencingTest extends \PHPUnit\Framework\TestCase
|
||||||
{
|
{
|
||||||
public function testReferenceStaticMethodArrayCallable()
|
|
||||||
{
|
|
||||||
$this->assertSame(4, ref([TestObject::class, 'twice'])(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testReferenceClosure()
|
public function testReferenceClosure()
|
||||||
{
|
{
|
||||||
$f = function () { };
|
$f = function () { };
|
||||||
@ -63,9 +53,11 @@ class ReferencingTest extends \PHPUnit\Framework\TestCase
|
|||||||
$this->assertSame('foo', $reference());
|
$this->assertSame('foo', $reference());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Kadet\Functional\Exception\NoAccessException
|
||||||
|
*/
|
||||||
public function testReferencingPrivateFunctionsFromOutsideOfObject()
|
public function testReferencingPrivateFunctionsFromOutsideOfObject()
|
||||||
{
|
{
|
||||||
$this->expectException(\Kadet\Functional\Exception\NoAccessException::class);
|
|
||||||
$object = new TestObject();
|
$object = new TestObject();
|
||||||
|
|
||||||
ref([$object, 'priv']);
|
ref([$object, 'priv']);
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class ReflectionPartialAppliedFunctionTest extends \PHPUnit\Framework\TestCase
|
|||||||
*/
|
*/
|
||||||
private $function;
|
private $function;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp()
|
||||||
{
|
{
|
||||||
$this->function = f\partial(function (array $a, array $b, array $c, array $d = ['d']) {
|
$this->function = f\partial(function (array $a, array $b, array $c, array $d = ['d']) {
|
||||||
return array_merge($a, $b, $c, $d);
|
return array_merge($a, $b, $c, $d);
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
<?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\\', []);
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user