From 898351301474cb3c93cab9c59b2363710be60672 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:20:44 +0000 Subject: [PATCH 1/4] Add array-shape return type for `localeconv()` and shape-based `localtime()` return type extension - Replace the bare `array` return type of `localeconv()` in `resources/functionMap.php` with the full array shape (string keys such as `decimal_point`/`thousands_sep`, int keys such as `frac_digits`, and `grouping: list`), so accessing individual entries yields `string`/`int`/`list` instead of `mixed`. - Add `LocaltimeFunctionDynamicReturnTypeExtension` for the sibling `localtime()` function, whose shape depends on the `associative_array` argument: a 9-element `list` by default, the `tm_*` associative shape when the argument is `true`, and the union of both when the argument is an unknown bool. --- resources/functionMap.php | 2 +- ...timeFunctionDynamicReturnTypeExtension.php | 65 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/localeconv.php | 26 ++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/localeconv.php diff --git a/resources/functionMap.php b/resources/functionMap.php index 07bda44a29e..724e61fa172 100644 --- a/resources/functionMap.php +++ b/resources/functionMap.php @@ -5449,7 +5449,7 @@ 'locale_lookup' => ['string|null', 'langtag'=>'array', 'locale'=>'string', 'canonicalize='=>'bool', 'defaultLocale='=>'string'], 'locale_parse' => ['array|null', 'locale'=>'string'], 'locale_set_default' => ['bool', 'locale'=>'string'], -'localeconv' => ['array'], +'localeconv' => ['array{decimal_point: string, thousands_sep: string, int_curr_symbol: string, currency_symbol: string, mon_decimal_point: string, mon_thousands_sep: string, positive_sign: string, negative_sign: string, int_frac_digits: int, frac_digits: int, p_cs_precedes: int, p_sep_by_space: int, n_cs_precedes: int, n_sep_by_space: int, p_sign_posn: int, n_sign_posn: int, grouping: list}'], 'localtime' => ['array', 'timestamp='=>'int', 'associative_array='=>'bool'], 'log' => ['float', 'number'=>'float', 'base='=>'float'], 'log10' => ['float', 'number'=>'float'], diff --git a/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php new file mode 100644 index 00000000000..13e811d59a3 --- /dev/null +++ b/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php @@ -0,0 +1,65 @@ +getName() === 'localtime'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type + { + $args = $functionCall->getArgs(); + + $associativeType = count($args) >= 2 ? $scope->getType($args[1]->value)->toBoolean() : null; + + if ($associativeType !== null && $associativeType->isTrue()->yes()) { + return $this->createAssociativeType(); + } + + if ($associativeType === null || $associativeType->isFalse()->yes()) { + return $this->createListType(); + } + + return TypeCombinator::union($this->createListType(), $this->createAssociativeType()); + } + + private function createListType(): Type + { + $integerType = new IntegerType(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < 9; $i++) { + $builder->setOffsetValueType(null, $integerType); + } + + return $builder->getArray(); + } + + private function createAssociativeType(): Type + { + $integerType = new IntegerType(); + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach (['tm_sec', 'tm_min', 'tm_hour', 'tm_mday', 'tm_mon', 'tm_year', 'tm_wday', 'tm_yday', 'tm_isdst'] as $key) { + $builder->setOffsetValueType(new ConstantStringType($key), $integerType); + } + + return $builder->getArray(); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/localeconv.php b/tests/PHPStan/Analyser/nsrt/localeconv.php new file mode 100644 index 00000000000..66b321c0d1b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/localeconv.php @@ -0,0 +1,26 @@ +}', $conv); + + assertType('string', $conv['thousands_sep']); + assertType('string', $conv['decimal_point']); + assertType('int', $conv['frac_digits']); + assertType('list', $conv['grouping']); +}; + +function (int $timestamp, bool $assoc): void { + assertType('array{int, int, int, int, int, int, int, int, int}', localtime()); + assertType('array{int, int, int, int, int, int, int, int, int}', localtime($timestamp)); + assertType('array{int, int, int, int, int, int, int, int, int}', localtime($timestamp, false)); + assertType('array{tm_sec: int, tm_min: int, tm_hour: int, tm_mday: int, tm_mon: int, tm_year: int, tm_wday: int, tm_yday: int, tm_isdst: int}', localtime($timestamp, true)); + assertType('array{int, int, int, int, int, int, int, int, int}|array{tm_sec: int, tm_min: int, tm_hour: int, tm_mday: int, tm_mon: int, tm_year: int, tm_wday: int, tm_yday: int, tm_isdst: int}', localtime($timestamp, $assoc)); +}; From e0ca3284ad2680c1699807511e2f40e1900a78c5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Jul 2026 11:59:20 +0000 Subject: [PATCH 2/4] Use precise integer ranges for localtime() struct fields Express the documented value ranges (e.g. tm_sec 0-59, tm_hour 0-23) as int instead of a plain int for both the list and associative localtime() return shapes. Co-Authored-By: Claude Opus 4.8 --- ...timeFunctionDynamicReturnTypeExtension.php | 32 +++++++++++++++---- tests/PHPStan/Analyser/nsrt/localeconv.php | 10 +++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php index 13e811d59a3..fcbe9cd396c 100644 --- a/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php @@ -9,6 +9,7 @@ use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -42,10 +43,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, private function createListType(): Type { - $integerType = new IntegerType(); $builder = ConstantArrayTypeBuilder::createEmpty(); - for ($i = 0; $i < 9; $i++) { - $builder->setOffsetValueType(null, $integerType); + foreach ($this->createFieldTypes() as [, $valueType]) { + $builder->setOffsetValueType(null, $valueType); } return $builder->getArray(); @@ -53,13 +53,33 @@ private function createListType(): Type private function createAssociativeType(): Type { - $integerType = new IntegerType(); $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach (['tm_sec', 'tm_min', 'tm_hour', 'tm_mday', 'tm_mon', 'tm_year', 'tm_wday', 'tm_yday', 'tm_isdst'] as $key) { - $builder->setOffsetValueType(new ConstantStringType($key), $integerType); + foreach ($this->createFieldTypes() as [$key, $valueType]) { + $builder->setOffsetValueType(new ConstantStringType($key), $valueType); } return $builder->getArray(); } + /** + * Fields of the C localtime struct in order, with the value ranges documented at + * https://www.php.net/manual/en/function.localtime.php + * + * @return list + */ + private function createFieldTypes(): array + { + return [ + ['tm_sec', IntegerRangeType::fromInterval(0, 59)], + ['tm_min', IntegerRangeType::fromInterval(0, 59)], + ['tm_hour', IntegerRangeType::fromInterval(0, 23)], + ['tm_mday', IntegerRangeType::fromInterval(1, 31)], + ['tm_mon', IntegerRangeType::fromInterval(0, 11)], + ['tm_year', new IntegerType()], + ['tm_wday', IntegerRangeType::fromInterval(0, 6)], + ['tm_yday', IntegerRangeType::fromInterval(0, 365)], + ['tm_isdst', new IntegerType()], + ]; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/localeconv.php b/tests/PHPStan/Analyser/nsrt/localeconv.php index 66b321c0d1b..f23e3187292 100644 --- a/tests/PHPStan/Analyser/nsrt/localeconv.php +++ b/tests/PHPStan/Analyser/nsrt/localeconv.php @@ -18,9 +18,9 @@ function (): void { }; function (int $timestamp, bool $assoc): void { - assertType('array{int, int, int, int, int, int, int, int, int}', localtime()); - assertType('array{int, int, int, int, int, int, int, int, int}', localtime($timestamp)); - assertType('array{int, int, int, int, int, int, int, int, int}', localtime($timestamp, false)); - assertType('array{tm_sec: int, tm_min: int, tm_hour: int, tm_mday: int, tm_mon: int, tm_year: int, tm_wday: int, tm_yday: int, tm_isdst: int}', localtime($timestamp, true)); - assertType('array{int, int, int, int, int, int, int, int, int}|array{tm_sec: int, tm_min: int, tm_hour: int, tm_mday: int, tm_mon: int, tm_year: int, tm_wday: int, tm_yday: int, tm_isdst: int}', localtime($timestamp, $assoc)); + assertType('array{int<0, 59>, int<0, 59>, int<0, 23>, int<1, 31>, int<0, 11>, int, int<0, 6>, int<0, 365>, int}', localtime()); + assertType('array{int<0, 59>, int<0, 59>, int<0, 23>, int<1, 31>, int<0, 11>, int, int<0, 6>, int<0, 365>, int}', localtime($timestamp)); + assertType('array{int<0, 59>, int<0, 59>, int<0, 23>, int<1, 31>, int<0, 11>, int, int<0, 6>, int<0, 365>, int}', localtime($timestamp, false)); + assertType('array{tm_sec: int<0, 59>, tm_min: int<0, 59>, tm_hour: int<0, 23>, tm_mday: int<1, 31>, tm_mon: int<0, 11>, tm_year: int, tm_wday: int<0, 6>, tm_yday: int<0, 365>, tm_isdst: int}', localtime($timestamp, true)); + assertType('array{int<0, 59>, int<0, 59>, int<0, 23>, int<1, 31>, int<0, 11>, int, int<0, 6>, int<0, 365>, int}|array{tm_sec: int<0, 59>, tm_min: int<0, 59>, tm_hour: int<0, 23>, tm_mday: int<1, 31>, tm_mon: int<0, 11>, tm_year: int, tm_wday: int<0, 6>, tm_yday: int<0, 365>, tm_isdst: int}', localtime($timestamp, $assoc)); }; From 4750d91cf9b4c6d330612b0b47aef7ab7433743e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Jul 2026 12:12:42 +0000 Subject: [PATCH 3/4] Add missing mon_grouping key to localeconv() array shape Co-Authored-By: Claude Opus 4.8 --- resources/functionMap.php | 2 +- tests/PHPStan/Analyser/nsrt/localeconv.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/functionMap.php b/resources/functionMap.php index 724e61fa172..a216ff776a5 100644 --- a/resources/functionMap.php +++ b/resources/functionMap.php @@ -5449,7 +5449,7 @@ 'locale_lookup' => ['string|null', 'langtag'=>'array', 'locale'=>'string', 'canonicalize='=>'bool', 'defaultLocale='=>'string'], 'locale_parse' => ['array|null', 'locale'=>'string'], 'locale_set_default' => ['bool', 'locale'=>'string'], -'localeconv' => ['array{decimal_point: string, thousands_sep: string, int_curr_symbol: string, currency_symbol: string, mon_decimal_point: string, mon_thousands_sep: string, positive_sign: string, negative_sign: string, int_frac_digits: int, frac_digits: int, p_cs_precedes: int, p_sep_by_space: int, n_cs_precedes: int, n_sep_by_space: int, p_sign_posn: int, n_sign_posn: int, grouping: list}'], +'localeconv' => ['array{decimal_point: string, thousands_sep: string, int_curr_symbol: string, currency_symbol: string, mon_decimal_point: string, mon_thousands_sep: string, positive_sign: string, negative_sign: string, int_frac_digits: int, frac_digits: int, p_cs_precedes: int, p_sep_by_space: int, n_cs_precedes: int, n_sep_by_space: int, p_sign_posn: int, n_sign_posn: int, grouping: list, mon_grouping: array}'], 'localtime' => ['array', 'timestamp='=>'int', 'associative_array='=>'bool'], 'log' => ['float', 'number'=>'float', 'base='=>'float'], 'log10' => ['float', 'number'=>'float'], diff --git a/tests/PHPStan/Analyser/nsrt/localeconv.php b/tests/PHPStan/Analyser/nsrt/localeconv.php index f23e3187292..94f25139fe5 100644 --- a/tests/PHPStan/Analyser/nsrt/localeconv.php +++ b/tests/PHPStan/Analyser/nsrt/localeconv.php @@ -9,7 +9,7 @@ function (): void { $conv = localeconv(); - assertType('array{decimal_point: string, thousands_sep: string, int_curr_symbol: string, currency_symbol: string, mon_decimal_point: string, mon_thousands_sep: string, positive_sign: string, negative_sign: string, int_frac_digits: int, frac_digits: int, p_cs_precedes: int, p_sep_by_space: int, n_cs_precedes: int, n_sep_by_space: int, p_sign_posn: int, n_sign_posn: int, grouping: list}', $conv); + assertType('array{decimal_point: string, thousands_sep: string, int_curr_symbol: string, currency_symbol: string, mon_decimal_point: string, mon_thousands_sep: string, positive_sign: string, negative_sign: string, int_frac_digits: int, frac_digits: int, p_cs_precedes: int, p_sep_by_space: int, n_cs_precedes: int, n_sep_by_space: int, p_sign_posn: int, n_sign_posn: int, grouping: list, mon_grouping: array}', $conv); assertType('string', $conv['thousands_sep']); assertType('string', $conv['decimal_point']); From cb99492daf0994666b407eaa41390bf29cbf6a05 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 10 Jul 2026 14:48:29 +0000 Subject: [PATCH 4/4] Default localtime() associative argument to ConstantBoolean(false) Co-Authored-By: Claude Opus 4.8 --- .../Php/LocaltimeFunctionDynamicReturnTypeExtension.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php index fcbe9cd396c..a58dd234c33 100644 --- a/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/LocaltimeFunctionDynamicReturnTypeExtension.php @@ -7,6 +7,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntegerRangeType; @@ -28,13 +29,13 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, { $args = $functionCall->getArgs(); - $associativeType = count($args) >= 2 ? $scope->getType($args[1]->value)->toBoolean() : null; + $associativeType = count($args) >= 2 ? $scope->getType($args[1]->value)->toBoolean() : new ConstantBooleanType(false); - if ($associativeType !== null && $associativeType->isTrue()->yes()) { + if ($associativeType->isTrue()->yes()) { return $this->createAssociativeType(); } - if ($associativeType === null || $associativeType->isFalse()->yes()) { + if ($associativeType->isFalse()->yes()) { return $this->createListType(); }