From dd4936e318529c973b007b4b05f47e435f02bec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Sat, 7 Nov 2026 17:15:01 +0100 Subject: [PATCH 1/4] Strings, Random: return types reflect non-empty strings - Strings::length() returns int<1, max> for a non-empty string and 0 for '' - Strings::lower(), firstLower(), upper(), firstUpper(), capitalize() and reverse() return a non-empty string for a non-empty string - Random::generate() always returns a non-empty string, as it throws for a length below 1 Co-Authored-By: Claude Code --- src/Utils/Random.php | 1 + src/Utils/Strings.php | 7 +++++++ tests/types/utils-types.php | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Utils/Random.php b/src/Utils/Random.php index f3ec874cf..62f0c1271 100644 --- a/src/Utils/Random.php +++ b/src/Utils/Random.php @@ -23,6 +23,7 @@ final class Random /** * Generates a random string of given length from characters specified in second argument. * Supports intervals, such as `0-9` or `A-Z`. + * @return non-empty-string */ public static function generate(int $length = 10, string $charlist = '0-9a-z'): string { diff --git a/src/Utils/Strings.php b/src/Utils/Strings.php index d12d042e1..8cc48dc79 100644 --- a/src/Utils/Strings.php +++ b/src/Utils/Strings.php @@ -292,6 +292,7 @@ public static function indent(string $s, int $level = 1, string $chars = "\t"): /** * Converts all characters of UTF-8 string to lower case. + * @return ($s is non-empty-string ? non-empty-string : string) */ public static function lower(string $s): string { @@ -301,6 +302,7 @@ public static function lower(string $s): string /** * Converts the first character of a UTF-8 string to lower case and leaves the other characters unchanged. + * @return ($s is non-empty-string ? non-empty-string : string) */ public static function firstLower(string $s): string { @@ -310,6 +312,7 @@ public static function firstLower(string $s): string /** * Converts all characters of a UTF-8 string to upper case. + * @return ($s is non-empty-string ? non-empty-string : string) */ public static function upper(string $s): string { @@ -319,6 +322,7 @@ public static function upper(string $s): string /** * Converts the first character of a UTF-8 string to upper case and leaves the other characters unchanged. + * @return ($s is non-empty-string ? non-empty-string : string) */ public static function firstUpper(string $s): string { @@ -328,6 +332,7 @@ public static function firstUpper(string $s): string /** * Converts the first character of every word of a UTF-8 string to upper case and the others to lower case. + * @return ($s is non-empty-string ? non-empty-string : string) */ public static function capitalize(string $s): string { @@ -389,6 +394,7 @@ public static function findPrefix(array $strings): string /** * Returns number of characters (not bytes) in UTF-8 string. * That is the number of Unicode code points which may differ from the number of graphemes. + * @return ($s is non-empty-string ? int<1, max> : 0) */ public static function length(string $s): int { @@ -436,6 +442,7 @@ public static function padRight(string $s, int $length, string $pad = ' '): stri /** * Reverses UTF-8 string. + * @return ($s is non-empty-string ? non-empty-string : string) */ public static function reverse(string $s): string { diff --git a/tests/types/utils-types.php b/tests/types/utils-types.php index 50a81ddf4..cb821cad6 100644 --- a/tests/types/utils-types.php +++ b/tests/types/utils-types.php @@ -6,7 +6,7 @@ */ use Nette\Iterators\CachingIterator; -use Nette\Utils\{ArrayHash, Arrays, Helpers, Html, Iterables, Type, Validators}; +use Nette\Utils\{ArrayHash, Arrays, Helpers, Html, Iterables, Random, Strings, Type, Validators}; use function PHPStan\Testing\assertType; @@ -252,3 +252,34 @@ function testIterablesFirstKeyWithElse(iterable $iterable): void { assertType('string', Iterables::firstKey($iterable, else: fn() => 'default')); } + + +/** @param non-empty-string $nonEmpty */ +function testStringsLength(string $string, string $nonEmpty): void +{ + assertType('int<1, max>', Strings::length('abc')); + assertType('int<1, max>', Strings::length($nonEmpty)); + assertType('0', Strings::length('')); + assertType('int<0, max>', Strings::length($string)); +} + + +/** @param non-empty-string $nonEmpty */ +function testStringsCaseConversion(string $string, string $nonEmpty): void +{ + assertType('non-empty-string', Strings::lower($nonEmpty)); + assertType('non-empty-string', Strings::firstLower($nonEmpty)); + assertType('non-empty-string', Strings::upper('abc')); + assertType('non-empty-string', Strings::firstUpper($nonEmpty)); + assertType('non-empty-string', Strings::capitalize($nonEmpty)); + assertType('non-empty-string', Strings::reverse($nonEmpty)); + assertType('string', Strings::lower($string)); + assertType('string', Strings::reverse($string)); +} + + +function testRandomGenerate(int $length): void +{ + assertType('non-empty-string', Random::generate()); + assertType('non-empty-string', Random::generate($length, 'a-z')); +} From 4c18032e8c6fe696a6283a6d8c3719ef72a17f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Sat, 7 Nov 2026 17:15:07 +0100 Subject: [PATCH 2/4] Strings, Arrays, ArrayHash, ArrayList, CachingIterator: positions and counts are non-negative - Strings::indexOf(), Arrays::getKeyOffset() and searchKey() return ?int<0, max> - ArrayHash::count(), ArrayList::count() and CachingIterator::getCounter() (and its $counter property) return int<0, max> Co-Authored-By: Claude Code --- src/Iterators/CachingIterator.php | 4 ++- src/Utils/ArrayHash.php | 1 + src/Utils/ArrayList.php | 1 + src/Utils/Arrays.php | 2 ++ src/Utils/Strings.php | 1 + tests/Iterators/CachingIterator.basic.phpt | 14 +++++++++++ tests/types/utils-types.php | 29 +++++++++++++++++++++- 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Iterators/CachingIterator.php b/src/Iterators/CachingIterator.php index 4c2830cb4..30fc54fad 100644 --- a/src/Iterators/CachingIterator.php +++ b/src/Iterators/CachingIterator.php @@ -21,7 +21,7 @@ * @property-read bool $empty * @property-read bool $odd * @property-read bool $even - * @property-read int $counter + * @property-read int<0,max> $counter * @property-read TKey $nextKey * @property-read TValue $nextValue */ @@ -29,6 +29,7 @@ class CachingIterator extends \CachingIterator implements \Countable { use Nette\SmartObject; + /** @var int<0, max> */ private int $counter = 0; @@ -78,6 +79,7 @@ public function isEven(): bool } + /** @return int<0, max> */ public function getCounter(): int { return $this->counter; diff --git a/src/Utils/ArrayHash.php b/src/Utils/ArrayHash.php index cb19b3633..8b19a1c33 100644 --- a/src/Utils/ArrayHash.php +++ b/src/Utils/ArrayHash.php @@ -47,6 +47,7 @@ public function &getIterator(): \Iterator } + /** @return int<0, max> */ public function count(): int { return count((array) $this); diff --git a/src/Utils/ArrayList.php b/src/Utils/ArrayList.php index 7852ce819..bbd4129ee 100644 --- a/src/Utils/ArrayList.php +++ b/src/Utils/ArrayList.php @@ -50,6 +50,7 @@ public function &getIterator(): \Iterator } + /** @return int<0, max> */ public function count(): int { return count($this->list); diff --git a/src/Utils/Arrays.php b/src/Utils/Arrays.php index 51916a609..01e07a52d 100644 --- a/src/Utils/Arrays.php +++ b/src/Utils/Arrays.php @@ -95,6 +95,7 @@ public static function mergeTree(array $array1, array $array2): array /** * Returns zero-indexed position of given array key. Returns null if key is not found. * @param array $array + * @return ?int<0, max> */ public static function getKeyOffset(array $array, string|int $key): ?int { @@ -104,6 +105,7 @@ public static function getKeyOffset(array $array, string|int $key): ?int /** * @param array $array + * @return ?int<0, max> * @deprecated use getKeyOffset() */ public static function searchKey(array $array, string|int $key): ?int diff --git a/src/Utils/Strings.php b/src/Utils/Strings.php index 8cc48dc79..b352f9a10 100644 --- a/src/Utils/Strings.php +++ b/src/Utils/Strings.php @@ -486,6 +486,7 @@ public static function after(string $haystack, string $needle, int $nth = 1): ?s /** * Returns position in characters of $nth occurence of $needle in $haystack or null if the $needle was not found. * Negative value of `$nth` means searching from the end. + * @return ?int<0, max> */ public static function indexOf(string $haystack, string $needle, int $nth = 1): ?int { diff --git a/tests/Iterators/CachingIterator.basic.phpt b/tests/Iterators/CachingIterator.basic.phpt index 9bdf0847f..a50b3e664 100644 --- a/tests/Iterators/CachingIterator.basic.phpt +++ b/tests/Iterators/CachingIterator.basic.phpt @@ -69,3 +69,17 @@ test('', function () { Assert::same(0, $iterator->getCounter()); Assert::true($iterator->isEmpty()); }); + + +test('magic properties', function () { + $iterator = new Iterators\CachingIterator(['a' => 'Nette', 'b' => 'Framework']); + $iterator->rewind(); + Assert::true($iterator->first); + Assert::false($iterator->last); + Assert::false($iterator->empty); + Assert::true($iterator->odd); + Assert::false($iterator->even); + Assert::same(1, $iterator->counter); + Assert::same('b', $iterator->nextKey); + Assert::same('Framework', $iterator->nextValue); +}); diff --git a/tests/types/utils-types.php b/tests/types/utils-types.php index cb821cad6..f596c244b 100644 --- a/tests/types/utils-types.php +++ b/tests/types/utils-types.php @@ -6,7 +6,7 @@ */ use Nette\Iterators\CachingIterator; -use Nette\Utils\{ArrayHash, Arrays, Helpers, Html, Iterables, Random, Strings, Type, Validators}; +use Nette\Utils\{ArrayHash, ArrayList, Arrays, Helpers, Html, Iterables, Random, Strings, Type, Validators}; use function PHPStan\Testing\assertType; @@ -278,8 +278,35 @@ function testStringsCaseConversion(string $string, string $nonEmpty): void } +function testStringsIndexOf(string $string): void +{ + assertType('int<0, max>|null', Strings::indexOf($string, 'x')); +} + + function testRandomGenerate(int $length): void { assertType('non-empty-string', Random::generate()); assertType('non-empty-string', Random::generate($length, 'a-z')); } + + +/** @param array $array */ +function testArraysGetKeyOffset(array $array): void +{ + assertType('int<0, max>|null', Arrays::getKeyOffset($array, 'key')); +} + + +/** + * @param ArrayHash $hash + * @param ArrayList $list + * @param CachingIterator $iterator + */ +function testCounts(ArrayHash $hash, ArrayList $list, CachingIterator $iterator): void +{ + assertType('int<0, max>', $hash->count()); + assertType('int<0, max>', $list->count()); + assertType('int<0, max>', $iterator->getCounter()); + assertType('int<0, max>', $iterator->counter); +} From 8be54385bd504b7409d6a8f2f63f4df94e5c4f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Sat, 7 Nov 2026 17:15:11 +0100 Subject: [PATCH 3/4] Arrays: grep() and wrap() keep the key types, flatten() returns a list flatten() returns a list unless $preserveKeys is true. Co-Authored-By: Claude Code --- src/Utils/Arrays.php | 12 +++++++----- tests/types/utils-types.php | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Utils/Arrays.php b/src/Utils/Arrays.php index 01e07a52d..7ebbf9e5d 100644 --- a/src/Utils/Arrays.php +++ b/src/Utils/Arrays.php @@ -255,8 +255,9 @@ public static function renameKey(array &$array, string|int $oldKey, string|int $ /** * Returns only those array items, which matches a regular expression $pattern. - * @param string[] $array - * @return string[] + * @template K of array-key + * @param array $array + * @return array */ public static function grep( array $array, @@ -273,7 +274,7 @@ public static function grep( /** * Transforms multidimensional array to flat array. * @param array $array - * @return array + * @return ($preserveKeys is true ? array : list) */ public static function flatten(array $array, bool $preserveKeys = false): array { @@ -559,8 +560,9 @@ public static function toKey(mixed $value): int|string /** * Returns a copy of $array where every item is cast to string and wrapped with $prefix and $suffix. - * @param string[] $array - * @return string[] + * @template K of array-key + * @param array $array + * @return array */ public static function wrap(array $array, string $prefix = '', string $suffix = ''): array { diff --git a/tests/types/utils-types.php b/tests/types/utils-types.php index f596c244b..b82913998 100644 --- a/tests/types/utils-types.php +++ b/tests/types/utils-types.php @@ -298,6 +298,24 @@ function testArraysGetKeyOffset(array $array): void } +/** @param array $array */ +function testArraysGrepAndWrap(array $array): void +{ + assertType('array', Arrays::grep($array, '#x#')); + assertType('array', Arrays::wrap($array, '<', '>')); + assertType('array<3|5, string>', Arrays::wrap([3 => 'a', 5 => 'b'])); +} + + +/** @param array $array */ +function testArraysFlatten(array $array, bool $preserveKeys): void +{ + assertType('list', Arrays::flatten($array)); + assertType('array', Arrays::flatten($array, preserveKeys: true)); + assertType('array', Arrays::flatten($array, $preserveKeys)); +} + + /** * @param ArrayHash $hash * @param ArrayList $list From 0c1da8460bba8d0c5fdc2c391d45c0db2cf53099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mor=C3=A1vek?= Date: Sat, 7 Nov 2026 17:15:11 +0100 Subject: [PATCH 4/4] Validators: isUnicode() narrows the value to string Co-Authored-By: Claude Code --- src/Utils/Validators.php | 1 + tests/types/utils-types.php | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/Utils/Validators.php b/src/Utils/Validators.php index 2c583e754..18025f397 100644 --- a/src/Utils/Validators.php +++ b/src/Utils/Validators.php @@ -252,6 +252,7 @@ public static function isCallable(mixed $value): bool /** * Checks if the value is a valid UTF-8 string. + * @phpstan-assert-if-true string $value */ public static function isUnicode(mixed $value): bool { diff --git a/tests/types/utils-types.php b/tests/types/utils-types.php index b82913998..ce6aecd50 100644 --- a/tests/types/utils-types.php +++ b/tests/types/utils-types.php @@ -328,3 +328,11 @@ function testCounts(ArrayHash $hash, ArrayList $list, CachingIterator $iterator) assertType('int<0, max>', $iterator->getCounter()); assertType('int<0, max>', $iterator->counter); } + + +function testValidatorsIsUnicode(mixed $value): void +{ + if (Validators::isUnicode($value)) { + assertType('string', $value); + } +}