diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php index 63c815ff521..c99c3fb0e5b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php @@ -9,6 +9,8 @@ use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer; use Psalm\Internal\Analyzer\StatementsAnalyzer; use Psalm\Internal\DataFlow\DataFlowNode; +use Psalm\Issue\MixedOperand; +use Psalm\IssueBuffer; use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent; use Psalm\Type; use Psalm\Type\Atomic\TLiteralFloat; @@ -16,11 +18,16 @@ use Psalm\Type\Atomic\TLiteralString; use Psalm\Type\Atomic\TNonEmptyNonspecificLiteralString; use Psalm\Type\Atomic\TNonEmptyString; +use Psalm\Type\Atomic\TNonFalsyString; use Psalm\Type\Atomic\TNonspecificLiteralInt; use Psalm\Type\Atomic\TNonspecificLiteralString; use Psalm\Type\Atomic\TString; use Psalm\Type\Union; +use function array_map; +use function array_merge; +use function array_unique; +use function count; use function in_array; /** @@ -28,6 +35,8 @@ */ final class EncapsulatedStringAnalyzer { + private const MAX_LITERALS = 500; + public static function analyze( StatementsAnalyzer $statements_analyzer, PhpParser\Node\Scalar\Encapsed $stmt, @@ -35,11 +44,12 @@ public static function analyze( ): bool { $parent_nodes = []; + $non_falsy = false; $non_empty = false; $all_literals = true; - $literal_string = ""; + $literal_strings = []; foreach ($stmt->parts as $part) { if (ExpressionAnalyzer::analyze($statements_analyzer, $part, $context) === false) { @@ -47,11 +57,22 @@ public static function analyze( } if ($part instanceof EncapsedStringPart) { - if ($literal_string !== null) { - $literal_string .= $part->value; + if ($literal_strings !== null) { + $literal_strings = self::combineLiteral($literal_strings, $part->value); } + $non_falsy = $non_falsy || $part->value; $non_empty = $non_empty || $part->value !== ""; } elseif ($part_type = $statements_analyzer->node_data->getType($part)) { + if ($part_type->hasMixed()) { + IssueBuffer::maybeAdd( + new MixedOperand( + 'Operands cannot be mixed', + new CodeLocation($statements_analyzer, $part), + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + $casted_part_type = CastAnalyzer::castStringAttempt( $statements_analyzer, $context, @@ -61,27 +82,64 @@ public static function analyze( if (!$casted_part_type->allLiterals()) { $all_literals = false; - } elseif (!$non_empty) { + } + + if (!$non_falsy) { // Check if all literals are nonempty - $non_empty = true; + $possibly_non_empty = true; + $non_falsy = true; foreach ($casted_part_type->getAtomicTypes() as $atomic_literal) { + if (($atomic_literal instanceof TLiteralInt && $atomic_literal->value === 0) + || ($atomic_literal instanceof TLiteralFloat && $atomic_literal->value === (float) 0) + || ($atomic_literal instanceof TLiteralString && $atomic_literal->value === "0") + ) { + $non_falsy = false; + + if ($non_empty) { + break; + } + } + if (!$atomic_literal instanceof TLiteralInt && !$atomic_literal instanceof TNonspecificLiteralInt && !$atomic_literal instanceof TLiteralFloat && !$atomic_literal instanceof TNonEmptyNonspecificLiteralString && !($atomic_literal instanceof TLiteralString && $atomic_literal->value !== "") ) { - $non_empty = false; - break; + if (!$atomic_literal instanceof TNonFalsyString) { + $non_falsy = false; + } + + if (!$atomic_literal instanceof TNonEmptyString) { + $possibly_non_empty = false; + $non_falsy = false; + + break; + } } } + + if ($possibly_non_empty) { + $non_empty = true; + } } - if ($literal_string !== null) { - if ($casted_part_type->isSingleLiteral()) { - $literal_string .= $casted_part_type->getSingleLiteral()->value; + if ($literal_strings !== null) { + if ($casted_part_type->allSpecificLiterals()) { + $new_literal_strings = []; + foreach ($casted_part_type->getLiteralStrings() as $literal_string_atomic) { + $new_literal_strings = array_merge( + $new_literal_strings, + self::combineLiteral($literal_strings, $literal_string_atomic->value), + ); + } + + $literal_strings = array_unique($new_literal_strings); + if (count($literal_strings) > self::MAX_LITERALS) { + $literal_strings = null; + } } else { - $literal_string = null; + $literal_strings = null; } } @@ -115,14 +173,14 @@ public static function analyze( } } else { $all_literals = false; - $literal_string = null; + $literal_strings = null; } } - if ($non_empty) { - if ($literal_string !== null) { + if ($non_empty || $non_falsy) { + if ($literal_strings !== null && $literal_strings !== []) { $stmt_type = new Union( - [Type::getAtomicStringFromLiteral($literal_string)], + array_map([Type::class, 'getAtomicStringFromLiteral'], $literal_strings), ['parent_nodes' => $parent_nodes], ); } elseif ($all_literals) { @@ -130,6 +188,11 @@ public static function analyze( [new TNonEmptyNonspecificLiteralString()], ['parent_nodes' => $parent_nodes], ); + } elseif ($non_falsy) { + $stmt_type = new Union( + [new TNonFalsyString()], + ['parent_nodes' => $parent_nodes], + ); } else { $stmt_type = new Union( [new TNonEmptyString()], @@ -152,4 +215,28 @@ public static function analyze( return true; } + + /** + * @param string[] $literal_strings + * @return non-empty-array + */ + private static function combineLiteral( + array $literal_strings, + string $append + ): array { + if ($literal_strings === []) { + return [$append]; + } + + if ($append === '') { + return $literal_strings; + } + + $new_literal_strings = array(); + foreach ($literal_strings as $literal_string) { + $new_literal_strings[] = "{$literal_string}{$append}"; + } + + return $new_literal_strings; + } } diff --git a/src/Psalm/Storage/ClassConstantStorage.php b/src/Psalm/Storage/ClassConstantStorage.php index 072f80a0439..b36ea5a9f8f 100644 --- a/src/Psalm/Storage/ClassConstantStorage.php +++ b/src/Psalm/Storage/ClassConstantStorage.php @@ -116,7 +116,7 @@ public function getHoverMarkdown(string $const): string $types = $this->type->getAtomicTypes(); $type = array_values($types)[0]; if (property_exists($type, 'value')) { - /** @psalm-suppress UndefinedPropertyFetch */ + /** @psalm-suppress UndefinedPropertyFetch, MixedOperand */ $value = " = {$type->value};"; } } diff --git a/tests/BinaryOperationTest.php b/tests/BinaryOperationTest.php index e2dafc18724..d9b97785c5a 100644 --- a/tests/BinaryOperationTest.php +++ b/tests/BinaryOperationTest.php @@ -828,6 +828,48 @@ function foo(string $s1): string { return "Hello $s1 $s2"; }', ], + 'encapsedStringIncludingLiterals3' => [ + 'code' => ' ['$interpolated===' => "non-falsy-string"], + ], + 'encapsedStringWithoutLiterals1' => [ + 'code' => ' ['$interpolated===' => "non-falsy-string"], + ], + 'encapsedStringWithoutLiterals2' => [ + 'code' => ' ['$interpolated===' => "non-empty-string"], + ], + 'encapsedStringWithoutLiterals3' => [ + 'code' => ' ['$interpolated===' => "non-falsy-string"], + ], + 'encapsedStringWithoutLiterals4' => [ + 'code' => ' ['$interpolated===' => "non-falsy-string"], + ], 'encapsedStringIsInferredAsLiteral' => [ 'code' => ' [ + 'code' => ' ['$encapsed===' => "'0'|'2'"], + ], + 'encapsedWithUnionLiteralsKeepsLiteral2' => [ + 'code' => ' ['$encapsed===' => "'XaYbyeZ'|'XaYhelloZ'|'XaYworldZ'|'XbYbyeZ'|'XbYhelloZ'|'XbYworldZ'"], + ], + 'encapsedWithIntsKeepsLiteral' => [ + 'code' => ' ['$encapsed===' => "'a0'|'a1'|'a2'|'b0'|'b1'|'b2'"], + ], + 'encapsedWithIntRangeKeepsLiteral' => [ + 'code' => ' $b + */ + $encapsed = "{$a}{$b}";', + 'assertions' => ['$encapsed===' => "'a0'|'a1'|'a2'|'b0'|'b1'|'b2'"], + ], 'NumericStringIncrement' => [ 'code' => ' 'LessSpecificReturnStatement', ], + 'encapsedMixedIssue10942' => [ + 'code' => ' 'MixedOperand', + ], ]; } } diff --git a/tests/SwitchTypeTest.php b/tests/SwitchTypeTest.php index 1560504d266..5bd7f0c52a0 100644 --- a/tests/SwitchTypeTest.php +++ b/tests/SwitchTypeTest.php @@ -245,7 +245,7 @@ function foo() : void { $x = rand() % 4; case 2: if (isset($x) && $x > 2) { - echo "$x is large"; + echo "large " . (string) $x; } break; } diff --git a/tests/TypeReconciliation/RedundantConditionTest.php b/tests/TypeReconciliation/RedundantConditionTest.php index 289bc20738f..c7aa2cc1614 100644 --- a/tests/TypeReconciliation/RedundantConditionTest.php +++ b/tests/TypeReconciliation/RedundantConditionTest.php @@ -751,7 +751,7 @@ function propertyInUse(array $tokens, int $i): bool { /** @param mixed $value */ function test($value) : void { if (!is_numeric($value)) { - throw new Exception("Invalid $value"); + throw new Exception("Invalid"); } if (!is_string($value)) {} }', diff --git a/tests/UnusedVariableTest.php b/tests/UnusedVariableTest.php index 15bf8b182a3..b51cb0ae84b 100644 --- a/tests/UnusedVariableTest.php +++ b/tests/UnusedVariableTest.php @@ -486,7 +486,7 @@ function use_static() : void { if (!$token) { $token = rand(1, 10); } - echo "token is $token\n"; + echo "token is " . (string) $token; }', ], 'staticVarUsedLater' => [