From 2f5653b88c11422625257d121806bee87986b28b Mon Sep 17 00:00:00 2001 From: kkmuffme <11071985+kkmuffme@users.noreply.github.com> Date: Sun, 5 May 2024 20:55:02 +0200 Subject: [PATCH] Fix int-range concat to behave like union of int types Fix https://github.com/vimeo/psalm/issues/10947 --- .../Expression/BinaryOp/ConcatAnalyzer.php | 62 +++++++++++++++++-- src/Psalm/Internal/Type/TypeExpander.php | 14 +++++ src/Psalm/Type/UnionTrait.php | 33 ++++++++++ tests/BinaryOperationTest.php | 18 ++++++ 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php index a870acd6a70..03d991f45e3 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; @@ -26,9 +28,13 @@ use Psalm\Issue\PossiblyNullOperand; use Psalm\IssueBuffer; use Psalm\Type; +use Psalm\Type\Atomic; 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,6 +46,7 @@ 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; @@ -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); + } + + if ($left_type_parts !== [] && $right_type_parts !== [] + && count($left_type_parts) * count($right_type_parts) <= 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,38 @@ private static function analyzeOperand( } } } + + /** + * @param array $type_parts + * @return array + */ + private static function intRangeToInts( + Codebase $codebase, + array $type_parts + ): array { + foreach ($type_parts as $key => $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) { + $type_parts = []; + break; + } + + foreach ($atomic_from_range as $atomic_int) { + $type_parts[] = $atomic_int; + } + + unset($type_parts[$key]); + } + } + + return $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..c8e1ba65668 100644 --- a/src/Psalm/Type/UnionTrait.php +++ b/src/Psalm/Type/UnionTrait.php @@ -1145,6 +1145,8 @@ public function allFloatLiterals(): bool * array-key, * TLiteralString|TLiteralInt|TLiteralFloat|TFalse|TTrue * > $this->getAtomicTypes() + * + * @psalm-suppress PossiblyUnusedMethod */ public function allSpecificLiterals(): bool { @@ -1162,6 +1164,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/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' => '