diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index d0bfe5486cb..5df0bc588e1 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -6446,7 +6446,7 @@ 'litespeed_request_headers' => ['array'], 'litespeed_response_headers' => ['array'], 'Locale::acceptFromHttp' => ['string|false', 'header'=>'string'], -'Locale::canonicalize' => ['string', 'locale'=>'string'], +'Locale::canonicalize' => ['?string', 'locale'=>'string'], 'Locale::composeLocale' => ['string', 'subtags'=>'array'], 'Locale::filterMatches' => ['?bool', 'languageTag'=>'string', 'locale'=>'string', 'canonicalize='=>'bool'], 'Locale::getAllVariants' => ['array', 'locale'=>'string'], @@ -9418,9 +9418,9 @@ 'preg_filter' => ['string|string[]|null', 'pattern'=>'string|string[]', 'replacement'=>'string|string[]', 'subject'=>'string|string[]', 'limit='=>'int', '&w_count='=>'int'], 'preg_grep' => ['array|false', 'pattern'=>'string', 'array'=>'array', 'flags='=>'int'], 'preg_last_error' => ['int'], -'preg_match' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'], -'preg_match\'1' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], -'preg_match_all' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], +'preg_match' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'], +'preg_match\'1' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], +'preg_match_all' => ['int<0,max>|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], 'preg_quote' => ['string', 'str'=>'string', 'delimiter='=>'?string'], 'preg_replace' => ['string|string[]|null', 'pattern'=>'string|array', 'replacement'=>'string|array', 'subject'=>'string|array', 'limit='=>'int', '&w_count='=>'int'], 'preg_replace_callback' => ['string|null', 'pattern'=>'string|array', 'callback'=>'callable(string[]):string', 'subject'=>'string', 'limit='=>'int', '&w_count='=>'int', 'flags='=>'int'], diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 37681adfec7..127e0f81311 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -3393,7 +3393,7 @@ 'LimitIterator::seek' => ['int', 'offset'=>'int'], 'LimitIterator::valid' => ['bool'], 'Locale::acceptFromHttp' => ['string|false', 'header'=>'string'], - 'Locale::canonicalize' => ['string', 'locale'=>'string'], + 'Locale::canonicalize' => ['?string', 'locale'=>'string'], 'Locale::composeLocale' => ['string', 'subtags'=>'array'], 'Locale::filterMatches' => ['?bool', 'languageTag'=>'string', 'locale'=>'string', 'canonicalize='=>'bool'], 'Locale::getAllVariants' => ['array', 'locale'=>'string'], @@ -13499,9 +13499,9 @@ 'preg_filter' => ['string|string[]|null', 'pattern'=>'string|string[]', 'replacement'=>'string|string[]', 'subject'=>'string|string[]', 'limit='=>'int', '&w_count='=>'int'], 'preg_grep' => ['array|false', 'pattern'=>'string', 'array'=>'array', 'flags='=>'int'], 'preg_last_error' => ['int'], - 'preg_match' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'], - 'preg_match\'1' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], - 'preg_match_all' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], + 'preg_match' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'], + 'preg_match\'1' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], + 'preg_match_all' => ['int<0,max>|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], 'preg_quote' => ['string', 'str'=>'string', 'delimiter='=>'string'], 'preg_replace' => ['string|string[]|null', 'pattern'=>'string|array', 'replacement'=>'string|array', 'subject'=>'string|array', 'limit='=>'int', '&w_count='=>'int'], 'preg_replace_callback' => ['string|null', 'pattern'=>'string|array', 'callback'=>'callable(string[]):string', 'subject'=>'string', 'limit='=>'int', '&w_count='=>'int'], diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index ed22c279d97..d5e9d090dc4 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -329,21 +329,7 @@ public static function getFileReportOptions(array $report_file_paths, bool $show { $report_options = []; - $mapping = [ - 'checkstyle.xml' => Report::TYPE_CHECKSTYLE, - 'sonarqube.json' => Report::TYPE_SONARQUBE, - 'codeclimate.json' => Report::TYPE_CODECLIMATE, - 'summary.json' => Report::TYPE_JSON_SUMMARY, - 'junit.xml' => Report::TYPE_JUNIT, - '.xml' => Report::TYPE_XML, - '.json' => Report::TYPE_JSON, - '.txt' => Report::TYPE_TEXT, - '.emacs' => Report::TYPE_EMACS, - '.pylint' => Report::TYPE_PYLINT, - '.console' => Report::TYPE_CONSOLE, - '.sarif' => Report::TYPE_SARIF, - 'count.txt' => Report::TYPE_COUNT, - ]; + $mapping = Report::getMapping(); foreach ($report_file_paths as $report_file_path) { foreach ($mapping as $extension => $type) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php index 5442bfae87a..2fd7cd1b5a7 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php @@ -63,6 +63,7 @@ use Psalm\Type\Union; use UnexpectedValueException; +use function array_filter; use function count; use function explode; use function implode; @@ -955,12 +956,29 @@ public static function verifyType( ) { $potential_method_ids = []; + $param_types_without_callable = array_filter( + $param_type->getAtomicTypes(), + static fn(Atomic $atomic) => !$atomic instanceof Atomic\TCallableInterface, + ); + $param_type_without_callable = [] !== $param_types_without_callable + ? new Union($param_types_without_callable) + : null; + foreach ($input_type->getAtomicTypes() as $input_type_part) { if ($input_type_part instanceof TList) { $input_type_part = $input_type_part->getKeyedArray(); } if ($input_type_part instanceof TKeyedArray) { + // If the param accept an array, we don't report arrays as wrong callbacks. + if (null !== $param_type_without_callable && UnionTypeComparator::isContainedBy( + $codebase, + $input_type, + $param_type_without_callable, + )) { + continue; + } + $potential_method_id = CallableTypeComparator::getCallableMethodIdFromTKeyedArray( $input_type_part, $codebase, @@ -1006,6 +1024,15 @@ public static function verifyType( } elseif ($input_type_part instanceof TLiteralString && strpos($input_type_part->value, '::') ) { + // If the param also accept a string, we don't report string as wrong callbacks. + if (null !== $param_type_without_callable && UnionTypeComparator::isContainedBy( + $codebase, + $input_type, + $param_type_without_callable, + )) { + continue; + } + $parts = explode('::', $input_type_part->value); /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ $potential_method_id = new MethodIdentifier( diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php index 5832bb159fe..dbb659f9ff6 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php @@ -28,6 +28,7 @@ use Psalm\Type\Atomic\TFalse; use Psalm\Type\Atomic\TFloat; use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TList; use Psalm\Type\Atomic\TLiteralFloat; @@ -53,6 +54,7 @@ use function array_pop; use function array_values; use function get_class; +use function range; use function strtolower; /** @@ -537,6 +539,18 @@ public static function castFloatAttempt( continue; } + if ($atomic_type instanceof TIntRange + && $atomic_type->min_bound !== null + && $atomic_type->max_bound !== null + && ($atomic_type->max_bound - $atomic_type->min_bound) < 500 + ) { + foreach (range($atomic_type->min_bound, $atomic_type->max_bound) as $literal_int_value) { + $valid_floats[] = new TLiteralFloat((float) $literal_int_value); + } + + continue; + } + if ($atomic_type instanceof TInt) { if ($atomic_type instanceof TLiteralInt) { $valid_floats[] = new TLiteralFloat((float) $atomic_type->value); @@ -721,9 +735,17 @@ public static function castStringAttempt( || $atomic_type instanceof TNumeric ) { if ($atomic_type instanceof TLiteralInt || $atomic_type instanceof TLiteralFloat) { - $castable_types[] = Type::getAtomicStringFromLiteral((string) $atomic_type->value); + $valid_strings[] = Type::getAtomicStringFromLiteral((string) $atomic_type->value); } elseif ($atomic_type instanceof TNonspecificLiteralInt) { $castable_types[] = new TNonspecificLiteralString(); + } elseif ($atomic_type instanceof TIntRange + && $atomic_type->min_bound !== null + && $atomic_type->max_bound !== null + && ($atomic_type->max_bound - $atomic_type->min_bound) < 500 + ) { + foreach (range($atomic_type->min_bound, $atomic_type->max_bound) as $literal_int_value) { + $valid_strings[] = Type::getAtomicStringFromLiteral((string) $literal_int_value); + } } else { $castable_types[] = new TNumericString(); } diff --git a/src/Psalm/Internal/Cli/Psalm.php b/src/Psalm/Internal/Cli/Psalm.php index 30b6679ecd4..99d87b52269 100644 --- a/src/Psalm/Internal/Cli/Psalm.php +++ b/src/Psalm/Internal/Cli/Psalm.php @@ -31,11 +31,13 @@ use Psalm\Progress\VoidProgress; use Psalm\Report; use Psalm\Report\ReportOptions; +use ReflectionClass; use RuntimeException; use Symfony\Component\Filesystem\Path; use function array_filter; use function array_key_exists; +use function array_keys; use function array_map; use function array_merge; use function array_slice; @@ -67,11 +69,13 @@ use function preg_replace; use function realpath; use function setlocale; +use function sort; use function str_repeat; use function str_replace; use function strlen; use function strpos; use function substr; +use function wordwrap; use const DIRECTORY_SEPARATOR; use const JSON_THROW_ON_ERROR; @@ -87,6 +91,7 @@ require_once __DIR__ . '/../Composer.php'; require_once __DIR__ . '/../IncludeCollector.php'; require_once __DIR__ . '/../../IssueBuffer.php'; +require_once __DIR__ . '/../../Report.php'; /** * @internal @@ -1250,6 +1255,21 @@ private static function generateStubs( */ private static function getHelpText(): string { + $formats = []; + /** @var string $value */ + foreach ((new ReflectionClass(Report::class))->getConstants() as $constant => $value) { + if (strpos($constant, 'TYPE_') === 0) { + $formats[] = $value; + } + } + sort($formats); + $outputFormats = wordwrap(implode(', ', $formats), 75, "\n "); + + /** @psalm-suppress ImpureMethodCall */ + $reports = array_keys(Report::getMapping()); + sort($reports); + $reportFormats = wordwrap('"' . implode('", "', $reports) . '"', 75, "\n "); + return <<type !== null) { + if ($var_comment && $var_comment->type !== null) { $const_type = $var_comment->type; - $suppressed_issues = $var_comment->suppressed_issues; if ($var_comment->type_start !== null && $var_comment->type_end !== null @@ -1357,6 +1355,7 @@ private function visitClassConstDeclaration( } else { $const_type = $inferred_type; } + $suppressed_issues = $var_comment ? $var_comment->suppressed_issues : []; $attributes = []; foreach ($stmt->attrGroups as $attr_group) { @@ -1420,8 +1419,8 @@ private function visitClassConstDeclaration( $description, ); - if ($this->codebase->analysis_php_version_id >= 8_03_00 + && !$storage->final && $stmt->type === null ) { IssueBuffer::maybeAdd( diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index ad3f0a4a0d7..e44c08bfbc7 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -574,6 +574,7 @@ public static function parse( $info->variadic = isset($parsed_docblock->tags['psalm-variadic']); $info->pure = isset($parsed_docblock->tags['psalm-pure']) + || isset($parsed_docblock->tags['phpstan-pure']) || isset($parsed_docblock->tags['pure']); if (isset($parsed_docblock->tags['psalm-mutation-free'])) { diff --git a/src/Psalm/Internal/Type/TypeCombiner.php b/src/Psalm/Internal/Type/TypeCombiner.php index 5e05fea1cff..c37b7eeeb5c 100644 --- a/src/Psalm/Internal/Type/TypeCombiner.php +++ b/src/Psalm/Internal/Type/TypeCombiner.php @@ -1227,9 +1227,16 @@ private static function scrapeStringProperties( ) { $combination->value_types['string'] = new TNonEmptyString(); } elseif (get_class($type) === TNonEmptyNonspecificLiteralString::class - && $combination->value_types['string'] instanceof TNonEmptyString + && ( + $combination->value_types['string'] instanceof TNonEmptyString + || $combination->value_types['string'] instanceof TNonspecificLiteralString + ) ) { // do nothing + } elseif (get_class($type) === TNonspecificLiteralString::class + && get_class($combination->value_types['string']) === TNonEmptyNonspecificLiteralString::class + ) { + $combination->value_types['string'] = $type; } else { $combination->value_types['string'] = new TString(); } diff --git a/src/Psalm/Report.php b/src/Psalm/Report.php index fddfd452714..3ed578c06cf 100644 --- a/src/Psalm/Report.php +++ b/src/Psalm/Report.php @@ -97,4 +97,27 @@ protected function xmlEncode(string $data): string } abstract public function create(): string; + + /** + * @return array + */ + public static function getMapping(): array + { + return [ + 'checkstyle.xml' => self::TYPE_CHECKSTYLE, + 'sonarqube.json' => self::TYPE_SONARQUBE, + 'codeclimate.json' => self::TYPE_CODECLIMATE, + 'summary.json' => self::TYPE_JSON_SUMMARY, + 'junit.xml' => self::TYPE_JUNIT, + '.xml' => self::TYPE_XML, + '.sarif.json' => self::TYPE_SARIF, + '.json' => self::TYPE_JSON, + '.txt' => self::TYPE_TEXT, + '.emacs' => self::TYPE_EMACS, + '.pylint' => self::TYPE_PYLINT, + '.console' => self::TYPE_CONSOLE, + '.sarif' => self::TYPE_SARIF, + 'count.txt' => self::TYPE_COUNT, + ]; + } } diff --git a/stubs/CoreGenericFunctions.phpstub b/stubs/CoreGenericFunctions.phpstub index 3f0c9951b37..23acb168ce7 100644 --- a/stubs/CoreGenericFunctions.phpstub +++ b/stubs/CoreGenericFunctions.phpstub @@ -1487,7 +1487,7 @@ function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, & * ) * ) * ) $matches - * @return int|false + * @return int<0,max>|false * @psalm-ignore-falsable-return */ function preg_match_all($pattern, $subject, &$matches = [], int $flags = 1, int $offset = 0) {} diff --git a/stubs/extensions/redis.phpstub b/stubs/extensions/redis.phpstub index d14673868cf..97fd397b2dd 100644 --- a/stubs/extensions/redis.phpstub +++ b/stubs/extensions/redis.phpstub @@ -36,6 +36,7 @@ class Redis { /** @return false|int|Redis */ public function append(string $key, string $value) {} + /** @param array{string,string}|array{string}|string $credentials */ public function auth(mixed $credentials): bool {} public function bgSave(): bool {} diff --git a/tests/CallableTest.php b/tests/CallableTest.php index 5ca5f06986d..f59707faa35 100644 --- a/tests/CallableTest.php +++ b/tests/CallableTest.php @@ -2210,6 +2210,24 @@ function foo($arg): void {} foo(["a", "b"]);', ], + 'notCallableArray' => [ + 'code' => ' [ + 'code' => ' [ 'code' => ' 'ParentNotFound', ], + 'wrongCallableInUnion' => [ + 'code' => ' 'InvalidArgument', + ], ]; } } diff --git a/tests/CastTest.php b/tests/CastTest.php index fe93e5d7b3f..414aa70aedb 100644 --- a/tests/CastTest.php +++ b/tests/CastTest.php @@ -49,5 +49,15 @@ public function providerValidCodeParse(): iterable '$a===' => 'array{a: int, b: string, ...}', ], ]; + yield 'castIntRangeToString' => [ + 'code' => ' */ + $int_range = 2; + $string = (string) $int_range; + ', + 'assertions' => [ + '$string===' => "'-1'|'-2'|'-3'|'-4'|'-5'|'0'|'1'|'2'|'3'", + ], + ]; } } diff --git a/tests/Internal/Codebase/InternalCallMapHandlerTest.php b/tests/Internal/Codebase/InternalCallMapHandlerTest.php index 82909f86027..e5f6320fba5 100644 --- a/tests/Internal/Codebase/InternalCallMapHandlerTest.php +++ b/tests/Internal/Codebase/InternalCallMapHandlerTest.php @@ -198,7 +198,6 @@ class InternalCallMapHandlerTest extends TestCase 'infiniteiterator::getinneriterator' => ['8.1', '8.2', '8.3'], 'iteratoriterator::getinneriterator' => ['8.1', '8.2', '8.3'], 'limititerator::getinneriterator' => ['8.1', '8.2', '8.3'], - 'locale::canonicalize' => ['8.1', '8.2', '8.3'], 'locale::getallvariants' => ['8.1', '8.2', '8.3'], 'locale::getkeywords' => ['8.1', '8.2', '8.3'], 'locale::getprimarylanguage' => ['8.1', '8.2', '8.3'], diff --git a/tests/MissingClassConstTypeTest.php b/tests/MissingClassConstTypeTest.php index de413050fdb..adabb5ab269 100644 --- a/tests/MissingClassConstTypeTest.php +++ b/tests/MissingClassConstTypeTest.php @@ -27,6 +27,29 @@ class A { 'ignored_issues' => [], 'php_version' => '8.3', ], + 'no type; >= PHP 8.3; but class is final' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], + 'no type; >= PHP 8.3; but psalm-suppressed' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], 'no type; < PHP 8.3' => [ 'code' => <<<'PHP' ', ], ], + 'unionNonEmptyLiteralStringAndLiteralString' => [ + 'literal-string', + [ + 'non-empty-literal-string', + 'literal-string', + ], + ], + 'unionLiteralStringAndNonEmptyLiteralString' => [ + 'literal-string', + [ + 'literal-string', + 'non-empty-literal-string', + ], + ], ]; }