diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php index a870acd6a70..33b0503e034 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php @@ -5,6 +5,7 @@ use AssertionError; use PhpParser; use Psalm\CodeLocation; +use Psalm\Codebase; use Psalm\Config; use Psalm\Context; use Psalm\Internal\Analyzer\FunctionLikeAnalyzer; @@ -15,6 +16,7 @@ use Psalm\Internal\Type\Comparator\AtomicTypeComparator; use Psalm\Internal\Type\Comparator\TypeComparisonResult; use Psalm\Internal\Type\Comparator\UnionTypeComparator; +use Psalm\Internal\Type\TypeExpander; use Psalm\Issue\FalseOperand; use Psalm\Issue\ImplicitToStringCast; use Psalm\Issue\ImpureMethodCall; @@ -29,6 +31,9 @@ use Psalm\Type\Atomic\TFalse; use Psalm\Type\Atomic\TFloat; use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TIntRange; +use Psalm\Type\Atomic\TLiteralFloat; +use Psalm\Type\Atomic\TLiteralInt; use Psalm\Type\Atomic\TLiteralString; use Psalm\Type\Atomic\TLowercaseString; use Psalm\Type\Atomic\TNamedObject; @@ -40,9 +45,11 @@ use Psalm\Type\Atomic\TNumericString; use Psalm\Type\Atomic\TString; use Psalm\Type\Atomic\TTemplateParam; +use Psalm\Type\Atomic\TTrue; use Psalm\Type\Union; use UnexpectedValueException; +use function assert; use function count; use function reset; use function strlen; @@ -52,7 +59,7 @@ */ final class ConcatAnalyzer { - private const MAX_LITERALS = 64; + private const MAX_LITERALS = 500; public static function analyze( StatementsAnalyzer $statements_analyzer, @@ -156,17 +163,26 @@ public static function analyze( // If both types are specific literals, combine them into new literals $literal_concat = false; - - if ($left_type->allSpecificLiterals() && $right_type->allSpecificLiterals()) { + if ($left_type->allSpecificLiteralsOrRange() && $right_type->allSpecificLiteralsOrRange()) { $left_type_parts = $left_type->getAtomicTypes(); $right_type_parts = $right_type->getAtomicTypes(); + $combinations = count($left_type_parts) * count($right_type_parts); - if ($combinations < self::MAX_LITERALS) { + if ($combinations > self::MAX_LITERALS) { + $left_type_parts = []; + $right_type_parts = []; + } else { + $left_type_parts = self::intRangeToInts($codebase, $left_type_parts); + $right_type_parts = self::intRangeToInts($codebase, $right_type_parts); + $combinations = count($left_type_parts) * count($right_type_parts); + } + + if ($combinations !== 0 && $combinations <= self::MAX_LITERALS) { $literal_concat = true; $result_type_parts = []; - foreach ($left_type->getAtomicTypes() as $left_type_part) { - foreach ($right_type->getAtomicTypes() as $right_type_part) { + foreach ($left_type_parts as $left_type_part) { + foreach ($right_type_parts as $right_type_part) { $literal = $left_type_part->value . $right_type_part->value; if (strlen($literal) >= $config->max_string_length) { // Literal too long, use non-literal type instead @@ -487,4 +503,42 @@ private static function analyzeOperand( } } } + + /** + * @param array $type_parts + * @return list + */ + private static function intRangeToInts( + Codebase $codebase, + array $type_parts + ): array { + $new_type_parts = []; + foreach ($type_parts as $atomic) { + if ($atomic instanceof TIntRange) { + $atomic_from_range = TypeExpander::expandAtomic( + $codebase, + $atomic, + null, + null, + null, + ); + + if ($atomic_from_range[0] === $atomic || count($atomic_from_range) > self::MAX_LITERALS) { + $new_type_parts = []; + break; + } + + foreach ($atomic_from_range as $atomic_int) { + assert($atomic_int instanceof TLiteralInt); + $new_type_parts[] = $atomic_int; + } + + continue; + } + + $new_type_parts[] = $atomic; + } + + return $new_type_parts; + } } diff --git a/src/Psalm/Internal/Type/TypeExpander.php b/src/Psalm/Internal/Type/TypeExpander.php index 8f7225ef37a..8338cc9a437 100644 --- a/src/Psalm/Internal/Type/TypeExpander.php +++ b/src/Psalm/Internal/Type/TypeExpander.php @@ -20,6 +20,7 @@ use Psalm\Type\Atomic\TInt; use Psalm\Type\Atomic\TIntMask; use Psalm\Type\Atomic\TIntMaskOf; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TIterable; use Psalm\Type\Atomic\TKeyOf; use Psalm\Type\Atomic\TKeyedArray; @@ -45,6 +46,7 @@ use function count; use function get_class; use function is_string; +use function range; use function reset; use function strtolower; @@ -437,6 +439,18 @@ public static function expandAtomic( return TypeParser::getComputedIntsFromMask($potential_ints); } + if ($return_type instanceof TIntRange + && $return_type->min_bound !== null + && $return_type->max_bound !== null + && ($return_type->max_bound - $return_type->min_bound) < 500 + ) { + $literal_ints = []; + foreach (range($return_type->min_bound, $return_type->max_bound) as $literal_int) { + $literal_ints[] = new TLiteralInt($literal_int); + } + return $literal_ints; + } + if ($return_type instanceof TConditional) { return self::expandConditional( $codebase, diff --git a/src/Psalm/Type/UnionTrait.php b/src/Psalm/Type/UnionTrait.php index b471b795df3..9938181e722 100644 --- a/src/Psalm/Type/UnionTrait.php +++ b/src/Psalm/Type/UnionTrait.php @@ -1140,6 +1140,7 @@ public function allFloatLiterals(): bool } /** + * @psalm-suppress PossiblyUnusedMethod * @psalm-mutation-free * @psalm-assert-if-true array< * array-key, @@ -1162,6 +1163,37 @@ public function allSpecificLiterals(): bool return true; } + /** + * @psalm-mutation-free + * @psalm-assert-if-true array< + * array-key, + * TLiteralString|TLiteralInt|TLiteralFloat|TFalse|TTrue|TIntRange + * > $this->getAtomicTypes() + */ + public function allSpecificLiteralsOrRange(): bool + { + foreach ($this->types as $atomic_key_type) { + if ($atomic_key_type instanceof TIntRange + && $atomic_key_type->min_bound !== null + && $atomic_key_type->max_bound !== null + && ($atomic_key_type->max_bound - $atomic_key_type->min_bound) < 500 + ) { + continue; + } + + if (!$atomic_key_type instanceof TLiteralString + && !$atomic_key_type instanceof TLiteralInt + && !$atomic_key_type instanceof TLiteralFloat + && !$atomic_key_type instanceof TFalse + && !$atomic_key_type instanceof TTrue + ) { + return false; + } + } + + return true; + } + /** * @psalm-mutation-free * @psalm-assert-if-true array< diff --git a/tests/ArrayFunctionCallTest.php b/tests/ArrayFunctionCallTest.php index 6abaf96ba68..903052b6a79 100644 --- a/tests/ArrayFunctionCallTest.php +++ b/tests/ArrayFunctionCallTest.php @@ -1691,7 +1691,19 @@ function makeKeyedArray(): array { return []; } $bar = array_intersect(... $foo);', 'assertions' => [ - '$bar' => 'array, int>', + '$bar' => 'array', + ], + ], + 'splatArrayIntersectExact' => [ + 'code' => ' [ + '$bar===' => 'array<0|1|2, 1|2|3>', ], ], 'arrayIntersectIsVariadic' => [ diff --git a/tests/BinaryOperationTest.php b/tests/BinaryOperationTest.php index e2dafc18724..3f135d73484 100644 --- a/tests/BinaryOperationTest.php +++ b/tests/BinaryOperationTest.php @@ -785,6 +785,24 @@ function foo($a, $b): void { '$a' => 'float', ], ], + 'concatWithIntsKeepsLiteral' => [ + 'code' => ' ['$interpolated===' => "'a0'|'a1'|'a2'|'b0'|'b1'|'b2'"], + ], + 'concatWithIntRangeKeepsLiteral' => [ + 'code' => ' $b + */ + $interpolated = $a . $b;', + 'assertions' => ['$interpolated===' => "'a0'|'a1'|'a2'|'b0'|'b1'|'b2'"], + ], 'literalConcatCreatesLiteral' => [ 'code' => ' 'int<-2, 0>', '$v===' => 'int<2, 0>', '$w===' => 'mixed', - '$x===' => 'int<0, 2>', - '$y===' => 'int<-2, 0>', + '$x===' => '0', + '$y===' => '0', '$z===' => 'mixed', '$aa===' => 'int<-2, 2>', '$ab===' => 'int<-2, 2>', @@ -335,7 +335,7 @@ function getInt(): int{return 0;} '$h===' => '0|1|float', '$i===' => 'int', '$j===' => 'float', - '$k===' => '-1', + '$k===' => '1', '$l===' => 'float|int', '$m===' => 'int<1, max>', '$n===' => 'float', @@ -349,7 +349,7 @@ function getInt(): int{return 0;} '$v===' => 'float', '$w===' => '1', '$x===' => '0', - '$y===' => 'float', + '$y===' => 'float(INF)', '$z===' => '1', '$aa===' => 'int<1, max>', '$ab===' => 'float', @@ -651,7 +651,7 @@ function getInt(): int{ return rand(0, 10); } } ', 'assertions' => [ - '$c===' => 'int<0, 0>|null', + '$c===' => '0|null', ], ], 'minMax' => [ diff --git a/tests/Template/FunctionTemplateTest.php b/tests/Template/FunctionTemplateTest.php index 4441f0e109c..21916bf1520 100644 --- a/tests/Template/FunctionTemplateTest.php +++ b/tests/Template/FunctionTemplateTest.php @@ -350,7 +350,7 @@ function splat_proof(array $arr, array $arr2) { $a = splat_proof(...$foo);', 'assertions' => [ - '$a' => 'array, int>', + '$a===' => 'array<0|1|2, 1|2|3>', ], ], 'passArrayByRef' => [