From b81fbbef465d5bdc4a499dd1c2ebcf8e5e34ac6f Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Mon, 11 May 2026 14:35:41 +0200 Subject: [PATCH 01/10] Guard `*scanf()` return type extension by counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the recently fixed `PrintfHelper::getScanfPlaceholdersCount()` to detect invalid format strings in the return type extension. If the format is uncountable, the call is guaranteed to fail – return `NullType` immediately. This is the same approach that eliminated the count regression. It already fixes all false‑positive type inferences for malformed formats. For example, invalid format (mixing positional %n$ with sequential %) returns `null`. Gegenprobe: the counter doesn’t guess – it asks PHP itself. --- .../SscanfFunctionDynamicReturnTypeExtension.php | 14 +++++++++++++- tests/PHPStan/Analyser/nsrt/sscanf.php | 4 ++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index de22ba0a461..ad2c3c0e97f 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Rules\Functions\PrintfHelper; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; @@ -15,6 +16,7 @@ use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NullType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -26,6 +28,10 @@ final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { + public function __construct(private PrintfHelper $printfHelper) + { + } + public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), ['sscanf', 'fscanf'], true); @@ -48,7 +54,13 @@ public function getTypeFromFunctionCall( return null; } - if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatType->getValue(), $matches) > 0) { + $formatValue = $formatType->getValue(); + $placeholderCount = $this->printfHelper->getScanfPlaceholdersCount($formatValue); + if ($placeholderCount === null) { + return new NullType(); + } + + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); for ($i = 0; $i < count($matches[0]); $i++) { diff --git a/tests/PHPStan/Analyser/nsrt/sscanf.php b/tests/PHPStan/Analyser/nsrt/sscanf.php index 484febdf9b4..2f58a13313d 100644 --- a/tests/PHPStan/Analyser/nsrt/sscanf.php +++ b/tests/PHPStan/Analyser/nsrt/sscanf.php @@ -55,3 +55,7 @@ function sscanfSuppression(string $s) { assertType('array{string|null}|null', sscanf($s, '%*d %s')); assertType('array{int|null}|null', sscanf($s, '%*[a-z]%d')); } + +function sscanfInvalidFormatMixingPositionalWithSequential(string $s) { + assertType('null', sscanf($s, '%1$s %s')); +} From c9e643ac4e41bd60bc3f82c7caa7806ab60e720d Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Mon, 11 May 2026 19:22:40 +0200 Subject: [PATCH 02/10] fixup! Guard `*scanf()` return type extension by counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return `NeverType` on PHP 8+ (where an invalid format throws `ValueError`) and `NullType` on PHP < 8.0. This makes the gatekeeper precise about the call never returning on modern PHP. The test strategy for the version‑dependent types will be settled in review – the CI now whispers `*NEVER*` where `null` stood before, and `array{}|null` where `array|null` stood before. --- .../SscanfFunctionDynamicReturnTypeExtension.php | 15 ++++++++++----- tests/PHPStan/Analyser/NodeScopeResolverTest.php | 6 ++++++ tests/PHPStan/Analyser/data/sscanf-php74.php | 9 +++++++++ tests/PHPStan/Analyser/data/sscanf-php80.php | 9 +++++++++ tests/PHPStan/Analyser/nsrt/sscanf.php | 4 ---- 5 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/sscanf-php74.php create mode 100644 tests/PHPStan/Analyser/data/sscanf-php80.php diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index ad2c3c0e97f..269eed43921 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\Rules\Functions\PrintfHelper; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; @@ -16,6 +17,7 @@ use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -28,7 +30,10 @@ final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private PrintfHelper $printfHelper) + public function __construct( + private PrintfHelper $printfHelper, + private PhpVersion $phpVersion, + ) { } @@ -57,11 +62,12 @@ public function getTypeFromFunctionCall( $formatValue = $formatType->getValue(); $placeholderCount = $this->printfHelper->getScanfPlaceholdersCount($formatValue); if ($placeholderCount === null) { - return new NullType(); + return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new NullType(); } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { - $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); for ($i = 0; $i < count($matches[0]); $i++) { $length = $matches[1][$i]; @@ -94,10 +100,9 @@ public function getTypeFromFunctionCall( $arrayBuilder->setOffsetValueType(new ConstantIntegerType($i), $type); } - return TypeCombinator::addNull($arrayBuilder->getArray()); } - return null; + return TypeCombinator::addNull($arrayBuilder->getArray()); } } diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index a8903a13df5..ced33a0ee59 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -284,6 +284,12 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-14124.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; + + if (PHP_VERSION_ID < 80000) { + yield __DIR__ . '/data/sscanf-php74.php'; + } else { + yield __DIR__ . '/data/sscanf-php80.php'; + } } /** diff --git a/tests/PHPStan/Analyser/data/sscanf-php74.php b/tests/PHPStan/Analyser/data/sscanf-php74.php new file mode 100644 index 00000000000..19b89bc0bb4 --- /dev/null +++ b/tests/PHPStan/Analyser/data/sscanf-php74.php @@ -0,0 +1,9 @@ + Date: Sat, 16 May 2026 19:26:22 +0200 Subject: [PATCH 03/10] Truncate `*scanf()` format at NUL byte nsrt tests Regression cases for the function's dynamic return type extension. Taken from the earlier PR. Paint the build red. --- tests/PHPStan/Analyser/nsrt/bug-14567.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14567.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14567.php b/tests/PHPStan/Analyser/nsrt/bug-14567.php new file mode 100644 index 00000000000..d838290164a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14567.php @@ -0,0 +1,19 @@ + Date: Sat, 16 May 2026 19:33:30 +0200 Subject: [PATCH 04/10] Truncate `*scanf()` format at NUL byte for return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In PHP’s sscanf/fscanf, a NUL byte (\0) in the format string terminates parsing at the C level. The return type extension was not truncating the format, so placeholders after a NUL could leak into the inferred type. Truncate the format string at the first NUL byte before matching conversion specifiers in SscanfFunctionDynamicReturnTypeExtension. The counter already handles NUL correctly because the runtime sscanf call stops at the NUL. --- src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index 269eed43921..6339f95d0bb 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -25,6 +25,7 @@ use function count; use function in_array; use function preg_match_all; +use function strstr; #[AutowiredService] final class SscanfFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -65,6 +66,11 @@ public function getTypeFromFunctionCall( return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new NullType(); } + $beforeNul = strstr($formatValue, "\0", true); + if ($beforeNul !== false) { + $formatValue = $beforeNul; + } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { From 2abfd0b801dee5a15ef33b73666627d0c43eb062 Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 19:54:47 +0200 Subject: [PATCH 05/10] Empty `*scanf()` format returns empty array type tests Paint the build red again: Refine the test data before refining the return type. --- tests/PHPStan/Analyser/nsrt/bug-14567.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14567.php b/tests/PHPStan/Analyser/nsrt/bug-14567.php index d838290164a..d04b32665ad 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14567.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14567.php @@ -8,12 +8,20 @@ function sscanfNulTerminator(string $s) { // NUL byte terminates sscanf format string - placeholders after \0 are ignored assertType('array{int|null}|null', sscanf($s, "%d\0%d")); assertType('array{int|null, string|null}|null', sscanf($s, "%d %s\0%d")); - assertType('array{}|null', sscanf($s, "\0%d%s")); + assertType('array{}', sscanf($s, "\0%d%s")); } function fscanfNulTerminator($r) { // Same for fscanf assertType('array{int|null}|null', fscanf($r, "%d\0%d")); assertType('array{int|null, string|null}|null', fscanf($r, "%d %s\0%d")); - assertType('array{}|null', fscanf($r, "\0%d%s")); + assertType('array{}', fscanf($r, "\0%d%s")); +} + +function sscanfEdgeCases(string $s) { + // Empty format string - no placeholders + assertType('array{}', sscanf($s, "")); + + // %% - literal percent, not a placeholder + assertType('array{}|null', sscanf($s, "%%")); } From ccdcf2865d635795b8e3518947df95e4a8b8d73e Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 20:05:31 +0200 Subject: [PATCH 06/10] Empty `*scanf()` format returns empty array type When the format string is empty (e.g. after NUL truncation, as can be seen on 3v4l.org [1]), sscanf returns an empty array, not null, on all PHP versions. Return the precise `array{}` type instead of falling through to the default `array{}|null` signature. [1]: https://3v4l.org/Xpbu5 --- src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index 6339f95d0bb..9bd7dcc86d5 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -73,6 +73,10 @@ public function getTypeFromFunctionCall( $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + if ($formatValue === '') { + return $arrayBuilder->getArray(); + } + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { for ($i = 0; $i < count($matches[0]); $i++) { From 26078933e18d7874378e60ddd789c162948a24fb Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 22:13:40 +0200 Subject: [PATCH 07/10] Skip regex when the counter says zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No need to run the preg_match_all machinery when we already know there are no placeholders – the counter has already told us. --- src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index 9bd7dcc86d5..a079f62fe8f 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -77,6 +77,10 @@ public function getTypeFromFunctionCall( return $arrayBuilder->getArray(); } + if ($placeholderCount === 0) { + return TypeCombinator::addNull($arrayBuilder->getArray()); + } + if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { for ($i = 0; $i < count($matches[0]); $i++) { From fb3c927db6b602fb3e5a3b03bb2b587cde846723 Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 20:54:36 +0200 Subject: [PATCH 08/10] Fix `*scanf()` seen and known cases testdata Update the return type extension to correctly handle the remaining specifier patterns found in phpstan's source and testdata corpus, as well as those reported in issues. Paint the build red: These tests are in part or in full LLM inferred from the earlier PR, let's see if we can recycle them. --- tests/PHPStan/Analyser/nsrt/bug-14567.php | 29 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/sscanf.php | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14567.php b/tests/PHPStan/Analyser/nsrt/bug-14567.php index d04b32665ad..85476395d5a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14567.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14567.php @@ -22,6 +22,35 @@ function sscanfEdgeCases(string $s) { // Empty format string - no placeholders assertType('array{}', sscanf($s, "")); + // %n - counts characters consumed, returns integer + assertType('array{int|null}|null', sscanf($s, "%n")); + // %% - literal percent, not a placeholder assertType('array{}|null', sscanf($s, "%%")); + + // %i - integer with base detection + assertType('array{int|null}|null', sscanf($s, "%i")); + + // %X - uppercase hex, same as %x + assertType('array{int|null}|null', sscanf($s, "%X")); + + // %D - uppercase alias for %d + assertType('array{int|null}|null', sscanf($s, "%D")); + + // %g - general float + assertType('array{float|null}|null', sscanf($s, "%g")); + + // %u - unsigned integer, can return string for values > PHP_INT_MAX + assertType('array{int|string|null}|null', sscanf($s, "%u")); + + // mixed specifiers with %n + assertType('array{int|null, int|null}|null', sscanf($s, "%d%n")); + + // Size modifiers (l, L, h) — consumed by ValidateFormat, no effect on PHP type + assertType('array{int|null}|null', sscanf($s, "%ld")); + assertType('array{float|null}|null', sscanf($s, "%lf")); + assertType('array{float|null}|null', sscanf($s, "%Lf")); + assertType('array{int|null}|null', sscanf($s, "%hd")); + assertType('array{int|string|null}|null', sscanf($s, "%lu")); + assertType('array{int|null, float|null, string|null}|null', sscanf($s, "%ld %lf %s")); } diff --git a/tests/PHPStan/Analyser/nsrt/sscanf.php b/tests/PHPStan/Analyser/nsrt/sscanf.php index 484febdf9b4..49bc59c7854 100644 --- a/tests/PHPStan/Analyser/nsrt/sscanf.php +++ b/tests/PHPStan/Analyser/nsrt/sscanf.php @@ -20,7 +20,7 @@ function sscanfFormatInference(string $s) { assertType('array{float|null}|null', sscanf($s, '%f')); assertType('array{int|null}|null', sscanf($s, '%o')); assertType('array{string|null}|null', sscanf($s, '%s')); - assertType('array{int|null}|null', sscanf($s, '%u')); + assertType('array{int|string|null}|null', sscanf($s, '%u')); assertType('array{int|null}|null', sscanf($s, '%x')); $mandate = "January 01 2000"; From 5be70b205233dc941dc9ecda8b3732a531a89751 Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 21:24:03 +0200 Subject: [PATCH 09/10] Fix `*scanf()` seen and known cases Gegenprobe Update the return type extension to correctly handle the remaining specifier patterns found in phpstan's source and testdata corpus, as well as those reported in issues. Paint the build red: Changes on top of existing tests due to improvements of the earlier LLM inferred tests under its own implementation. This looks better to me. Red still is the new green. --- tests/PHPStan/Analyser/nsrt/bug-14567.php | 10 +++++----- tests/PHPStan/Analyser/nsrt/bug-7563.php | 6 +++--- tests/PHPStan/Analyser/nsrt/bug-7764.php | 2 +- tests/PHPStan/Analyser/nsrt/sscanf.php | 18 ++++++++++-------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14567.php b/tests/PHPStan/Analyser/nsrt/bug-14567.php index 85476395d5a..3ffb1b6df35 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14567.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14567.php @@ -7,14 +7,14 @@ function sscanfNulTerminator(string $s) { // NUL byte terminates sscanf format string - placeholders after \0 are ignored assertType('array{int|null}|null', sscanf($s, "%d\0%d")); - assertType('array{int|null, string|null}|null', sscanf($s, "%d %s\0%d")); + assertType('array{int|null, non-empty-string|null}|null', sscanf($s, "%d %s\0%d")); assertType('array{}', sscanf($s, "\0%d%s")); } function fscanfNulTerminator($r) { // Same for fscanf assertType('array{int|null}|null', fscanf($r, "%d\0%d")); - assertType('array{int|null, string|null}|null', fscanf($r, "%d %s\0%d")); + assertType('array{int|null, non-empty-string|null}|null', fscanf($r, "%d %s\0%d")); assertType('array{}', fscanf($r, "\0%d%s")); } @@ -41,7 +41,7 @@ function sscanfEdgeCases(string $s) { assertType('array{float|null}|null', sscanf($s, "%g")); // %u - unsigned integer, can return string for values > PHP_INT_MAX - assertType('array{int|string|null}|null', sscanf($s, "%u")); + assertType('array{int|non-falsy-string|null}|null', sscanf($s, "%u")); // mixed specifiers with %n assertType('array{int|null, int|null}|null', sscanf($s, "%d%n")); @@ -51,6 +51,6 @@ function sscanfEdgeCases(string $s) { assertType('array{float|null}|null', sscanf($s, "%lf")); assertType('array{float|null}|null', sscanf($s, "%Lf")); assertType('array{int|null}|null', sscanf($s, "%hd")); - assertType('array{int|string|null}|null', sscanf($s, "%lu")); - assertType('array{int|null, float|null, string|null}|null', sscanf($s, "%ld %lf %s")); + assertType('array{int|non-falsy-string|null}|null', sscanf($s, "%lu")); + assertType('array{int|null, float|null, non-empty-string|null}|null', sscanf($s, "%ld %lf %s")); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7563.php b/tests/PHPStan/Analyser/nsrt/bug-7563.php index da259a876f3..7c811423eb5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7563.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7563.php @@ -11,7 +11,7 @@ class HelloWorld public static function sayHello(string $dimension): void { $result = sscanf($dimension, '%[1234567890.]%s'); - assertType('array{string|null, string|null}|null', $result); + assertType('array{non-empty-string|null, non-empty-string|null}|null', $result); } public static function sayFoo() { @@ -22,14 +22,14 @@ public static function sayFoo() { throw new Exception($error); } - assertType('array{string|null, int|null, string|null, int|null, string|null}', $exceptionComponents); + assertType('array{non-empty-string|null, int|null, non-empty-string|null, int|null, non-empty-string|null}', $exceptionComponents); } // see https://3v4l.org/Y5T2R public static function edgeCase(string $dimension): void { $result = sscanf($dimension, '%[%[]'); - assertType('array{string|null}|null', $result); + assertType('array{non-empty-string|null}|null', $result); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7764.php b/tests/PHPStan/Analyser/nsrt/bug-7764.php index 2583a46d6f1..71a6e8106bb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7764.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7764.php @@ -6,7 +6,7 @@ function doFoo() { $split = sscanf('hello/world', '%[^/]/%[^/]/%s'); - assertType('array{string|null, string|null, string|null}|null', $split); + assertType('array{non-empty-string|null, non-empty-string|null, non-empty-string|null}|null', $split); if (!is_array($split)) { echo 'Not array', "\n"; } elseif (count($split) > 1) { diff --git a/tests/PHPStan/Analyser/nsrt/sscanf.php b/tests/PHPStan/Analyser/nsrt/sscanf.php index 49bc59c7854..aba597a050b 100644 --- a/tests/PHPStan/Analyser/nsrt/sscanf.php +++ b/tests/PHPStan/Analyser/nsrt/sscanf.php @@ -13,26 +13,28 @@ function sscanfFormatInference(string $s) { assertType('int|null', sscanf($s, $s, $first, $second)); assertType('array|null', sscanf($s, $s)); - assertType('array{string|null}|null', sscanf($s, '%c')); + assertType('array{non-empty-string|null}|null', sscanf($s, '%c')); assertType('array{int|null}|null', sscanf($s, '%d')); + assertType('array{int|null}|null', sscanf($s, '%D')); assertType('array{float|null}|null', sscanf($s, '%e')); assertType('array{float|null}|null', sscanf($s, '%E')); assertType('array{float|null}|null', sscanf($s, '%f')); assertType('array{int|null}|null', sscanf($s, '%o')); - assertType('array{string|null}|null', sscanf($s, '%s')); - assertType('array{int|string|null}|null', sscanf($s, '%u')); + assertType('array{non-empty-string|null}|null', sscanf($s, '%s')); + assertType('array{int|non-falsy-string|null}|null', sscanf($s, '%u')); assertType('array{int|null}|null', sscanf($s, '%x')); + assertType('array{int|null}|null', sscanf($s, '%X')); $mandate = "January 01 2000"; list($month, $day, $year) = sscanf($mandate, "%s %d %d"); - assertType('string|null', $month); + assertType('non-empty-string|null', $month); assertType('int|null', $day); assertType('int|null', $year); } function fscanfFormatInference($r) { list($month, $day, $year) = fscanf($r, "%s %d %d"); - assertType('string|null', $month); + assertType('non-empty-string|null', $month); assertType('int|null', $day); assertType('int|null', $year); } @@ -43,8 +45,8 @@ function fooo(string $s) { assertType('array{non-empty-string|null}|null', sscanf( "123456" , "%0s")); assertType('array{non-empty-string|null}|null', sscanf( "123456" , "%1s")); - assertType('array{non-falsy-string|null}|null', sscanf( "123456" , "%2s")); - assertType('array{non-falsy-string|null}|null', sscanf( "123456" , "%3s")); + assertType('array{non-empty-string|null}|null', sscanf( "123456" , "%2s")); + assertType('array{non-empty-string|null}|null', sscanf( "123456" , "%3s")); assertType('array{int|null, int|null, int|null}|null', sscanf('00ccff', '%2x%2x%2x')); } @@ -52,6 +54,6 @@ function fooo(string $s) { function sscanfSuppression(string $s) { // %* means assignment suppression - these should not appear in return array assertType('array{int|null}|null', sscanf($s, '%*s %d')); - assertType('array{string|null}|null', sscanf($s, '%*d %s')); + assertType('array{non-empty-string|null}|null', sscanf($s, '%*d %s')); assertType('array{int|null}|null', sscanf($s, '%*[a-z]%d')); } From 7eee4176c296825202276dd97ed84c485e9f9c2b Mon Sep 17 00:00:00 2001 From: "Hans Krentel (hakre)" Date: Sat, 16 May 2026 22:55:40 +0200 Subject: [PATCH 10/10] Fix `*scanf()` seen and known cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the return type extension to correctly handle the remaining specifier patterns found in phpstan's source and testdata corpus, as well as those reported in issues. - Expand the regex to cover all valid specifiers (`%i`, `%D`, `%g`, ...)   and handle length modifiers (`h`, `l`, `L`). - Fix scanset parsing with `%[...]` to allow `]` as the first character   inside the set, matching the runtime behavior. - Eliminate false matches of `%%` using the "Black Magic" recipe   by NikiC: `(?:%%)+(*SKIP)(*FAIL)|%`. - Tighten string types to `non-empty-string` for `%s`, `%c`, `%[...]`,   which always return at least one character on a successful match. - Map `%u` to `int|non-falsy-string` to account for its overflow behavior. These changes build on the previously merged counter fix and the empty‑format / NUL‑byte guard. --- ...canfFunctionDynamicReturnTypeExtension.php | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php index a079f62fe8f..49d19b9a58c 100644 --- a/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SscanfFunctionDynamicReturnTypeExtension.php @@ -81,33 +81,39 @@ public function getTypeFromFunctionCall( return TypeCombinator::addNull($arrayBuilder->getArray()); } - if (preg_match_all('/%(\d*)(\[[^\]]+\]|[cdeEfosux]{1})/', $formatValue, $matches) > 0) { + if (preg_match_all('/(?:%%)+(*SKIP)(*FAIL)|%\d*[lLh]?(\[(?:\^?]?[^]]*)]|[cdDeEfginosuxX]{1})/', $formatValue, $matches) > 0) { for ($i = 0; $i < count($matches[0]); $i++) { - $length = $matches[1][$i]; - $specifier = $matches[2][$i]; + $specifier = $matches[1][$i][0]; - $type = new StringType(); - if ($length !== '') { - if (((int) $length) > 1) { + switch (true) { + case in_array($specifier, ['c', 's', '['], true): $type = new IntersectionType([ - $type, - new AccessoryNonFalsyStringType(), - ]); - } else { - $type = new IntersectionType([ - $type, + new StringType(), new AccessoryNonEmptyStringType(), ]); - } - } - - if (in_array($specifier, ['d', 'o', 'u', 'x'], true)) { - $type = new IntegerType(); - } - - if (in_array($specifier, ['e', 'E', 'f'], true)) { - $type = new FloatType(); + break; + //no fallthrough + case $specifier === 'u': + $type = TypeCombinator::union( + new IntegerType(), + new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ]), + ); + break; + //no fallthrough + case in_array($specifier, ['d', 'D', 'i', 'n', 'o', 'x', 'X'], true): + $type = new IntegerType(); + break; + //no fallthrough + case in_array($specifier, ['e', 'E', 'f', 'g'], true): + $type = new FloatType(); + break; + //no fallthrough + default: + return null; } $type = TypeCombinator::addNull($type);