diff --git a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php index de1e27d7a..ab43dcf5c 100644 --- a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php @@ -39,6 +39,7 @@ use Psalm\Internal\Provider\ReturnTypeProvider\ParseUrlReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\PowReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\RandReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\RoundReturnTypeProvider; +use Psalm\Internal\Provider\ReturnTypeProvider\SprintfReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\StrReplaceReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\StrTrReturnTypeProvider; use Psalm\Internal\Provider\ReturnTypeProvider\TriggerErrorReturnTypeProvider; @@ -105,6 +106,7 @@ class FunctionReturnTypeProvider $this->registerClass(MbInternalEncodingReturnTypeProvider::class); $this->registerClass(DateReturnTypeProvider::class); $this->registerClass(PowReturnTypeProvider::class); + $this->registerClass(SprintfReturnTypeProvider::class); } /** diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/SprintfReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/SprintfReturnTypeProvider.php new file mode 100644 index 000000000..5bdf54dae --- /dev/null +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/SprintfReturnTypeProvider.php @@ -0,0 +1,96 @@ + + */ + public static function getFunctionIds(): array + { + return [ + 'sprintf', + ]; + } + + public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): Union + { + $statements_source = $event->getStatementsSource(); + $node_type_provider = $statements_source->getNodeTypeProvider(); + + $call_args = $event->getCallArgs(); + foreach ($call_args as $index => $call_arg) { + $type = $node_type_provider->getType($call_arg->value); + if ($type === null) { + continue; + } + + if ($index === 0 && $type->isSingleStringLiteral()) { + // use empty string dummies to check if the format itself produces a non-empty return value + // faster than validating the pattern and checking all args separately + $dummy = array_fill(0, count($call_args) - 1, ''); + if (sprintf($type->getSingleStringLiteral()->value, ...$dummy) !== '') { + return Type::getNonEmptyString(); + } + } + + if ($index === 0) { + continue; + } + + // if the function has more arguments than the pattern has placeholders, this could be a false positive + // if the param is not used in the pattern + // however we would need to analyze the format arg to check that + // can be done eventually to also implement https://github.com/vimeo/psalm/issues/9818 + // and https://github.com/vimeo/psalm/issues/9817 + if ($type->isNonEmptyString() || $type->isInt() || $type->isFloat()) { + return Type::getNonEmptyString(); + } + + // check for unions of either + $atomic_types = $type->getAtomicTypes(); + if ($atomic_types === []) { + continue; + } + + foreach ($atomic_types as $atomic_type) { + if ($atomic_type instanceof TNonEmptyString + || $atomic_type instanceof TClassString + || ($atomic_type instanceof TLiteralString && $atomic_type->value !== '') + || $atomic_type instanceof TInt + || $atomic_type instanceof TFloat + || $atomic_type instanceof TNumeric) { + // valid non-empty types, potentially there are more though + continue; + } + + // empty or generic string + // or other unhandled type + continue 2; + } + + return Type::getNonEmptyString(); + } + + return Type::getString(); + } +} diff --git a/tests/ReturnTypeProvider/SprintfTest.php b/tests/ReturnTypeProvider/SprintfTest.php new file mode 100644 index 000000000..b0759da0d --- /dev/null +++ b/tests/ReturnTypeProvider/SprintfTest.php @@ -0,0 +1,124 @@ + [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfFormatNonEmpty' => [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfArgnumFormatNonEmpty' => [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfLiteralFormatNonEmpty' => [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfStringPlaceholderLiteralIntParamFormatNonEmpty' => [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfStringPlaceholderIntParamFormatNonEmpty' => [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfStringPlaceholderFloatParamFormatNonEmpty' => [ + 'code' => ' [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfStringPlaceholderIntStringParamFormatNonEmpty' => [ + 'code' => ' 5 ? time() : implode("", array()) . "hello"; + $val = sprintf("%s", $tmp); + ', + 'assertions' => [ + '$val===' => 'non-empty-string', + ], + ]; + + yield 'sprintfStringPlaceholderLiteralStringParamFormat' => [ + 'code' => ' [ + '$val===' => 'string', + ], + ]; + + yield 'sprintfStringPlaceholderStringParamFormat' => [ + 'code' => ' [ + '$val===' => 'string', + ], + ]; + + yield 'sprintfStringArgnumPlaceholderStringParamsFormat' => [ + 'code' => ' [ + '$val===' => 'string', + ], + ]; + + yield 'sprintfStringPlaceholderIntStringParamFormat' => [ + 'code' => ' 5 ? time() : implode("", array()); + $val = sprintf("%s", $tmp); + ', + 'assertions' => [ + '$val===' => 'string', + ], + ]; + } +}