From c13591db8bef714d12e8051edd48f233b815ad3b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 21 Oct 2025 11:23:56 +0200 Subject: [PATCH 1/6] Fix strtr inferences --- ...aceFunctionsDynamicReturnTypeExtension.php | 6 +++++ .../Rules/Methods/CallMethodsRuleTest.php | 10 ++++++++ .../PHPStan/Rules/Methods/data/bug-13708.php | 23 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-13708.php diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php index 4b2b3a8f98..198ae08701 100644 --- a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -101,6 +101,12 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( if ($replaceArgumentType->isArray()->yes()) { $replaceArgumentType = $replaceArgumentType->getIterableValueType(); } + } elseif ($functionReflection->getName() === 'strtr' && isset($functionCall->getArgs()[1])) { + // `strtr` has two signatures: `strtr($string1, $string2, $string3)` and `strtr($string1, $array)` + $secondArgumentType = $scope->getType($functionCall->getArgs()[1]->value); + if ($secondArgumentType->isArray()->yes()) { + $replaceArgumentType = $secondArgumentType->getIterableValueType(); + } } } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index dc02451edd..124780e739 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3664,6 +3664,16 @@ public function testBug5642(): void ]); } + public function testBug13708(): void + { + $this->checkThisOnly = false; + $this->checkNullables = false; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = false; + + $this->analyse([__DIR__ . '/data/bug-13708.php'], []); + } + public function testBug3396(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-13708.php b/tests/PHPStan/Rules/Methods/data/bug-13708.php new file mode 100644 index 0000000000..867a56ff52 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-13708.php @@ -0,0 +1,23 @@ +takeNonEmpty( + strtr('change {me}', ['{me}' => 'me']) + ); + } +} From 50bdf1671fa2514dc12df003c79ee6d8dab9449c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 21 Oct 2025 21:00:27 +0200 Subject: [PATCH 2/6] Add assertion --- tests/PHPStan/Analyser/nsrt/strtr.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/strtr.php b/tests/PHPStan/Analyser/nsrt/strtr.php index 5bc9fd6679..44dc4981c2 100644 --- a/tests/PHPStan/Analyser/nsrt/strtr.php +++ b/tests/PHPStan/Analyser/nsrt/strtr.php @@ -24,4 +24,16 @@ function doFoo(string $s, $nonEmptyString, $nonFalseyString) { assertType('non-empty-string', strtr($nonFalseyString, $s, $nonEmptyString)); assertType('non-falsy-string', strtr($nonFalseyString, $nonEmptyString, $nonFalseyString)); assertType('non-falsy-string', strtr($nonFalseyString, $nonFalseyString, $nonFalseyString)); + + assertType('string', strtr($s, [$s => $nonEmptyString])); + assertType('string', strtr($s, [$nonEmptyString => $nonEmptyString])); + assertType('string', strtr($s, [$nonFalseyString => $nonFalseyString])); + + assertType('non-empty-string', strtr($nonEmptyString, [$s => $nonEmptyString])); + assertType('non-empty-string', strtr($nonEmptyString, [$nonEmptyString => $nonEmptyString])); + assertType('non-empty-string', strtr($nonEmptyString, [$nonFalseyString => $nonFalseyString])); + + assertType('non-empty-string', strtr($nonFalseyString, [$s => $nonEmptyString])); + assertType('non-falsy-string', strtr($nonFalseyString, [$nonEmptyString => $nonFalseyString])); + assertType('non-falsy-string', strtr($nonFalseyString, [$nonFalseyString => $nonFalseyString])); } From 0a69e76bec6865e3ff1ad04062062acb5670bda0 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 10:55:03 +0100 Subject: [PATCH 3/6] Improve maybe inferences --- .../ReplaceFunctionsDynamicReturnTypeExtension.php | 5 ++++- tests/PHPStan/Analyser/nsrt/strtr.php | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php index 198ae08701..74f5fcedc2 100644 --- a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -103,7 +103,10 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( } } elseif ($functionReflection->getName() === 'strtr' && isset($functionCall->getArgs()[1])) { // `strtr` has two signatures: `strtr($string1, $string2, $string3)` and `strtr($string1, $array)` - $secondArgumentType = $scope->getType($functionCall->getArgs()[1]->value); + $secondArgumentType = TypeCombinator::intersect( + new ArrayType(new MixedType(), new MixedType()), + $scope->getType($functionCall->getArgs()[1]->value) + ); if ($secondArgumentType->isArray()->yes()) { $replaceArgumentType = $secondArgumentType->getIterableValueType(); } diff --git a/tests/PHPStan/Analyser/nsrt/strtr.php b/tests/PHPStan/Analyser/nsrt/strtr.php index 44dc4981c2..0ac591b546 100644 --- a/tests/PHPStan/Analyser/nsrt/strtr.php +++ b/tests/PHPStan/Analyser/nsrt/strtr.php @@ -7,8 +7,9 @@ /** * @param non-empty-string $nonEmptyString * @param non-falsy-string $nonFalseyString + * @param mixed $mixed */ -function doFoo(string $s, $nonEmptyString, $nonFalseyString) { +function doFoo(string $s, $nonEmptyString, $nonFalseyString, $mixed) { assertType('string', strtr($s, 'f', 'b')); assertType('string', strtr($s, ['f' => 'b'])); assertType('string', strtr($s, ['f' => 'b', 'o' => 'a'])); @@ -36,4 +37,15 @@ function doFoo(string $s, $nonEmptyString, $nonFalseyString) { assertType('non-empty-string', strtr($nonFalseyString, [$s => $nonEmptyString])); assertType('non-falsy-string', strtr($nonFalseyString, [$nonEmptyString => $nonFalseyString])); assertType('non-falsy-string', strtr($nonFalseyString, [$nonFalseyString => $nonFalseyString])); + + assertType('non-empty-string', strtr($nonEmptyString, rand(0, 1) ? [$s => $nonEmptyString] : null)); + assertType('non-empty-string', strtr($nonEmptyString, rand(0, 1) ? [$nonEmptyString => $nonEmptyString] : null)); + assertType('non-empty-string', strtr($nonEmptyString, rand(0, 1) ? [$nonFalseyString => $nonFalseyString] : null)); + + assertType('non-empty-string', strtr($nonFalseyString, rand(0, 1) ? [$s => $nonEmptyString] : null)); + assertType('non-falsy-string', strtr($nonFalseyString, rand(0, 1) ? [$nonEmptyString => $nonFalseyString] : null)); + assertType('non-falsy-string', strtr($nonFalseyString, rand(0, 1) ? [$nonFalseyString => $nonFalseyString] : null)); + + assertType('string', strtr($nonEmptyString, $mixed)); + assertType('string', strtr($nonFalseyString, $mixed)); } From 52fa18c653e710420c401ef88ab4260f5d683d18 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 11:17:51 +0100 Subject: [PATCH 4/6] Fix cs --- src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php index 74f5fcedc2..5177125bd8 100644 --- a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -105,7 +105,7 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( // `strtr` has two signatures: `strtr($string1, $string2, $string3)` and `strtr($string1, $array)` $secondArgumentType = TypeCombinator::intersect( new ArrayType(new MixedType(), new MixedType()), - $scope->getType($functionCall->getArgs()[1]->value) + $scope->getType($functionCall->getArgs()[1]->value), ); if ($secondArgumentType->isArray()->yes()) { $replaceArgumentType = $secondArgumentType->getIterableValueType(); From b80c847f7f935a4b9ab5ed387ff7687b81e2c385 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 5 Dec 2025 11:19:58 +0100 Subject: [PATCH 5/6] Try --- src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php index 5177125bd8..ab2b39e77b 100644 --- a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -107,9 +107,7 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( new ArrayType(new MixedType(), new MixedType()), $scope->getType($functionCall->getArgs()[1]->value), ); - if ($secondArgumentType->isArray()->yes()) { - $replaceArgumentType = $secondArgumentType->getIterableValueType(); - } + $replaceArgumentType = $secondArgumentType->getIterableValueType(); } } From de36dc2d9c42b2c8494f442ba253285080f6f523 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 9 Feb 2026 22:18:13 +0100 Subject: [PATCH 6/6] Add tests --- tests/PHPStan/Analyser/nsrt/strtr.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/strtr.php b/tests/PHPStan/Analyser/nsrt/strtr.php index 0ac591b546..861bae8f36 100644 --- a/tests/PHPStan/Analyser/nsrt/strtr.php +++ b/tests/PHPStan/Analyser/nsrt/strtr.php @@ -38,6 +38,10 @@ function doFoo(string $s, $nonEmptyString, $nonFalseyString, $mixed) { assertType('non-falsy-string', strtr($nonFalseyString, [$nonEmptyString => $nonFalseyString])); assertType('non-falsy-string', strtr($nonFalseyString, [$nonFalseyString => $nonFalseyString])); + // see https://3v4l.org/jCUhW/rfc#vgit.master + assertType('string', strtr($nonFalseyString, [$nonFalseyString => $s])); + assertType('string', strtr($nonEmptyString, [$nonEmptyString => $s])); + assertType('non-empty-string', strtr($nonEmptyString, rand(0, 1) ? [$s => $nonEmptyString] : null)); assertType('non-empty-string', strtr($nonEmptyString, rand(0, 1) ? [$nonEmptyString => $nonEmptyString] : null)); assertType('non-empty-string', strtr($nonEmptyString, rand(0, 1) ? [$nonFalseyString => $nonFalseyString] : null));