diff --git a/README.md b/README.md index aec7948..5889743 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ third to 2. To prevent that, two kinds of callables are called with the value on - **PHP's internal functions**, written as first-class callables: `trim(...)`, `round(...)`, `strtoupper(...)`. This also holds when they are wrapped, e.g. `f\partial(str_pad(...), _, 3, '0')`. -- **Closures declaring exactly one parameter**, like `fn ($value) => ...`. +- **Any callable declaring exactly one parameter**, like `fn ($value) => ...` or a one-parameter method. ```php [1.25, 2.5, 3.14159] |> c\map(round(...)); // [1.0, 3.0, 3.0], not [1.0, 2.5, 3.14] @@ -321,8 +321,10 @@ $byId->get('d4')->destination; // 'Oliwa' ### Queries `first`, `last`, `find`, `contains`, `any`, `some`, `every`, `count`, `isEmpty`, `min`, `max`, `reduce`, `sum` and -`join` turn a collection into a single value. They stop reading as soon as the answer is known, which matters for -generators. +`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 @@ -392,8 +394,8 @@ not copied), and results whose type says whether they are a list (`Seq`), a mapp ## Static analysis -Every function has `@template` generics, so PHPStan (level 8 in this repository) follows element types through a -chain, `|>` included: +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 $departures */ @@ -407,10 +409,17 @@ function delayedLines(Ds\Seq $departures): ?string ``` 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). The limits of what can be inferred: +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`, @@ -424,7 +433,7 @@ generator in, `Ds\Seq|list` out). The limits of what can be inferred: ```bash composer test # both PHPUnit suites -composer phpstan # PHPStan, level 8, on src/ and the type tests in tests/types/ +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 @@ -432,6 +441,6 @@ composer phpstan # PHPStan, level 8, on src/ and the type tests in tests/types/ 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. Add one when - you change a signature. +- `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. diff --git a/composer.json b/composer.json index 7631cd0..eea8789 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,9 @@ "phpunit", "phpunit -c phpunit.no-ds.xml" ], - "phpstan": "phpstan analyse" + "phpstan": [ + "phpstan analyse", + "phpstan analyse -c phpstan.types.neon" + ] } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c7e8de1..9a0bb93 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -168,12 +168,6 @@ parameters: count: 1 path: src/Predicates/objects.php - - - message: '#^Function Kadet\\Functional\\Predicates\\instance\(\) has no return type specified\.$#' - identifier: missingType.return - 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 @@ -270,36 +264,18 @@ parameters: count: 1 path: src/Reflection/ReflectionPartiallyAppliedFunction.php - - - message: '#^Function Kadet\\Functional\\Transforms\\field\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Transforms/objects.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 no return type specified\.$#' - identifier: missingType.return - 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 no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Transforms/objects.php - - message: '#^Function Kadet\\Functional\\Transforms\\method\(\) has parameter \$args with no type specified\.$#' identifier: missingType.parameter @@ -312,12 +288,6 @@ parameters: count: 1 path: src/Transforms/objects.php - - - message: '#^Function Kadet\\Functional\\Transforms\\property\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Transforms/objects.php - - message: '#^Function Kadet\\Functional\\Transforms\\property\(\) has parameter \$property with no type specified\.$#' identifier: missingType.parameter diff --git a/phpstan.neon b/phpstan.neon index c2c05bb..6b3afe7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,12 +1,10 @@ parameters: level: 8 - # tests/types checks inference through the pipe operator, which needs PHP 8.5 to parse. PHPStan parses with the - # lowest configured version, so this can't be a range starting at the library's PHP 8.2 floor; that floor is - # covered by running the test suite on PHP 8.2. - phpVersion: 80500 + # 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 - - tests/types ignoreErrors: - # Eager transforms are typed with a conditional closure type that maps the input container to the result diff --git a/phpstan.types.neon b/phpstan.types.neon new file mode 100644 index 0000000..f1ceaa3 --- /dev/null +++ b/phpstan.types.neon @@ -0,0 +1,7 @@ +# Type tests (tests/types): assertType() checks of what PHPStan infers for library users, including through the pipe +# operator, which needs PHP 8.5 to parse. The library itself is analysed against PHP 8.2 by phpstan.neon. +parameters: + level: 8 + phpVersion: 80500 + paths: + - tests/types diff --git a/src/Collections/Lazy/transforms.php b/src/Collections/Lazy/transforms.php index cc575b7..d9e4134 100644 --- a/src/Collections/Lazy/transforms.php +++ b/src/Collections/Lazy/transforms.php @@ -30,9 +30,9 @@ use function Kadet\Functional\Collections\Internal\unpairs; /** * Transforms each value with `$fn($value, $key)`, keeping the keys. * - * @template TKey - * @template TValue - * @template TResult + * @template TKey = mixed + * @template TValue = mixed + * @template TResult = mixed * * @param callable(TValue, TKey): TResult $fn * @@ -52,8 +52,8 @@ function map(callable $fn): \Closure /** * Keeps the values for which `$fn($value, $key)` is truthy, or the truthy values when `$fn` is null. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param (callable(TValue, TKey): mixed)|null $fn * @@ -75,8 +75,8 @@ function filter(?callable $fn = null): \Closure /** * Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $fn * @@ -98,9 +98,9 @@ function reject(callable $fn): \Closure /** * Maps each value to an iterable with `$fn($value, $key)` and flattens the results one level, with sequential keys. * - * @template TKey - * @template TValue - * @template TResult + * @template TKey = mixed + * @template TValue = mixed + * @template TResult = mixed * * @param callable(TValue, TKey): iterable $fn * @@ -170,8 +170,8 @@ function unique(): \Closure * Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence. * Selected values are compared strictly, objects by identity. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $selector * @@ -260,7 +260,7 @@ function skip(int $n): \Closure /** * Puts `$values` before the values of the input, with sequential keys. * - * @template TValue + * @template TValue = mixed * * @param TValue ...$values * @@ -282,7 +282,7 @@ function prepend(mixed ...$values): \Closure /** * Puts `$values` after the values of the input, with sequential keys. * - * @template TValue + * @template TValue = mixed * * @param TValue ...$values * @@ -296,7 +296,7 @@ function append(mixed ...$values): \Closure /** * Puts the values of `$other` after the values of the input, with sequential keys. * - * @template TValue + * @template TValue = mixed * * @param iterable $other * @@ -320,8 +320,8 @@ function concat(iterable $other): \Closure * * The return value of `$fn` is ignored. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $fn * @@ -343,8 +343,8 @@ function each(callable $fn): \Closure /** * Alias of {@see each()}. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $fn * diff --git a/src/Collections/collectors.php b/src/Collections/collectors.php index 1674089..3275d6d 100644 --- a/src/Collections/collectors.php +++ b/src/Collections/collectors.php @@ -23,9 +23,9 @@ 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 - * @template TValue - * @template TIndex + * @template TKey = mixed + * @template TValue = mixed + * @template TIndex = mixed * * @param callable(TValue, TKey): TIndex $selector * @@ -50,9 +50,9 @@ function keyBy(callable $selector): \Closure * 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 - * @template TValue - * @template TGroup + * @template TKey = mixed + * @template TValue = mixed + * @template TGroup = mixed * * @param callable(TValue, TKey): TGroup $selector * diff --git a/src/Collections/queries.php b/src/Collections/queries.php index 43bd7c2..d6f4f1f 100644 --- a/src/Collections/queries.php +++ b/src/Collections/queries.php @@ -32,8 +32,8 @@ use function Kadet\Functional\compile; * * Returns null when nothing matches; with `$required` it throws instead, so a found `null` stays distinguishable. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param (callable(TValue, TKey): mixed)|null $predicate * @@ -54,8 +54,8 @@ function first(?callable $predicate = null, bool $required = false): \Closure * 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 - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param (callable(TValue, TKey): mixed)|null $predicate * @@ -105,8 +105,8 @@ function last(?callable $predicate = null, bool $required = false): \Closure /** * Alias of {@see first()} with a mandatory predicate: returns the first value matching `$predicate($value, $key)`. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $predicate * @@ -142,8 +142,8 @@ function contains(mixed $value): \Closure /** * Whether any value satisfies `$predicate($value, $key)`. False for an empty collection. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $predicate * @@ -167,8 +167,8 @@ function any(callable $predicate): \Closure /** * Alias of {@see any()}. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $predicate * @@ -182,8 +182,8 @@ function some(callable $predicate): \Closure /** * Whether every value satisfies `$predicate($value, $key)`. True for an empty collection. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $predicate * @@ -250,9 +250,9 @@ function isEmpty(): \Closure * * With a selector this returns the selected value, not the element it was selected from. * - * @template TKey - * @template TValue - * @template TSelected + * @template TKey = mixed + * @template TValue = mixed + * @template TSelected = mixed * * @param (callable(TValue, TKey): TSelected)|null $selector * @@ -270,9 +270,9 @@ function min(?callable $selector = null): \Closure * * With a selector this returns the selected value, not the element it was selected from. * - * @template TKey - * @template TValue - * @template TSelected + * @template TKey = mixed + * @template TValue = mixed + * @template TSelected = mixed * * @param (callable(TValue, TKey): TSelected)|null $selector * @@ -288,9 +288,9 @@ function max(?callable $selector = null): \Closure /** * Folds the collection into a single value with `$fn($carry, $value, $key)`, starting from `$initial`. * - * @template TKey - * @template TValue - * @template TCarry + * @template TKey = mixed + * @template TValue = mixed + * @template TCarry = mixed * * @param callable(TCarry, TValue, TKey): TCarry $fn * @param TCarry $initial @@ -315,14 +315,12 @@ function reduce(callable $fn, mixed $initial = null): \Closure /** * Sums the values, or `$selector($value, $key)` when given. An empty collection sums to 0. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param (callable(TValue, TKey): (int|float))|null $selector * - * @return ($selector is null - * ? \Closure(iterable): (int|float) - * : \Closure(iterable): (int|float)) + * @return \Closure(iterable): (int|float) */ function sum(?callable $selector = null): \Closure { diff --git a/src/Collections/transforms.php b/src/Collections/transforms.php index 68265b3..63a4a9a 100644 --- a/src/Collections/transforms.php +++ b/src/Collections/transforms.php @@ -34,9 +34,9 @@ use function Kadet\Functional\compile; * * For `Ds\Set` this follows `Ds\Set::map`, so equal results collapse into one. * - * @template TKey - * @template TValue - * @template TResult + * @template TKey = mixed + * @template TValue = mixed + * @template TResult = mixed * * @param callable(TValue, TKey): TResult $fn * @@ -60,8 +60,8 @@ function map(callable $fn): \Closure * * Keys are preserved for arrays and `Ds\Map`; `Ds\Seq` is reindexed. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param (callable(TValue, TKey): mixed)|null $fn * @@ -83,8 +83,8 @@ function filter(?callable $fn = null): \Closure /** * Drops the values for which `$fn($value, $key)` is truthy. The inverse of {@see filter()}. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $fn * @@ -108,9 +108,9 @@ function reject(callable $fn): \Closure * * Arrays give a list array, `Ds\Set` gives `Ds\Set`, anything else gives `Ds\Seq`. * - * @template TKey - * @template TValue - * @template TResult + * @template TKey = mixed + * @template TValue = mixed + * @template TResult = mixed * * @param callable(TValue, TKey): iterable $fn * @@ -169,8 +169,8 @@ function unique(): \Closure * Removes values whose `$selector($value, $key)` result was already seen, keeping the first occurrence. * Selected values are compared strictly, objects by identity. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $selector * @@ -194,7 +194,7 @@ function uniqueBy(callable $selector): \Closure * * The sort is stable and keeps the keys of arrays and `Ds\Map`. The input is never modified. * - * @template TValue + * @template TValue = mixed * * @param (callable(TValue, TValue): int)|null $comparator * @@ -219,8 +219,8 @@ function sort(?callable $comparator = null): \Closure * 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. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $selector * @@ -291,7 +291,7 @@ function skip(int $n): \Closure /** * Puts `$values` before the values of the input, as a list (keys are dropped). * - * @template TValue + * @template TValue = mixed * * @param TValue ...$values * @@ -307,7 +307,7 @@ function prepend(mixed ...$values): \Closure /** * Puts `$values` after the values of the input, as a list (keys are dropped). * - * @template TValue + * @template TValue = mixed * * @param TValue ...$values * @@ -321,7 +321,7 @@ function append(mixed ...$values): \Closure /** * Puts the values of `$other` after the values of the input, as a list (keys are dropped). * - * @template TValue + * @template TValue = mixed * * @param iterable $other * @@ -339,8 +339,8 @@ function concat(iterable $other): \Closure * * Arrays and `Ds\Map` are merged by key; for `Ds\Seq`, `Ds\Set` and other iterables the values are appended. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param iterable $other * @@ -360,8 +360,8 @@ function merge(iterable $other): \Closure * 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 - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $fn * @@ -393,8 +393,8 @@ function each(callable $fn): \Closure /** * Alias of {@see each()}. * - * @template TKey - * @template TValue + * @template TKey = mixed + * @template TValue = mixed * * @param callable(TValue, TKey): mixed $fn * diff --git a/src/Predicates/objects.php b/src/Predicates/objects.php index 61d0980..07b7c88 100644 --- a/src/Predicates/objects.php +++ b/src/Predicates/objects.php @@ -49,6 +49,9 @@ function field(string $field, $predicate) return predicate(f\pipe(t\field($field), $predicate)); } +/** + * @return \Kadet\Functional\Predicate + */ function instance(string $class) { return new ClosurePredicate(function ($value) use ($class) { diff --git a/src/Transforms/objects.php b/src/Transforms/objects.php index 6263fe8..eca16f8 100644 --- a/src/Transforms/objects.php +++ b/src/Transforms/objects.php @@ -9,11 +9,17 @@ namespace Kadet\Functional\Transforms; +/** + * @return \Closure(object): mixed + */ function getter($property) { return method('get'.ucfirst($property)); } +/** + * @return \Closure(object): mixed + */ function property($property) { return function ($object) use ($property) { @@ -25,6 +31,9 @@ function property($property) }; } +/** + * @return \Closure(object): mixed + */ function field($field) { return function ($object) use ($field) { @@ -32,6 +41,9 @@ function field($field) }; } +/** + * @return \Closure(object): mixed + */ function method($method, ...$args) { return function ($object) use ($method, $args) { diff --git a/tests/types/collections.php b/tests/types/collections.php index 7b15336..d0c642f 100644 --- a/tests/types/collections.php +++ b/tests/types/collections.php @@ -17,6 +17,8 @@ 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; @@ -101,6 +103,24 @@ function queries(\Ds\Seq $foos, \Ds\Map $map, array $ints): void 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 $list + */ +function accessors(array $list): void +{ + assertType('array', c\map(t\property('name'))($list)); + assertType('array', 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', c\keyBy(t\property('id'))($list)); + assertType('array', 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. *