Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix and improve various things for encapsed string #10948

Open
wants to merge 4 commits into
base: 5.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,49 +9,70 @@
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;
use Psalm\Type\Atomic\TLiteralInt;
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;

/**
* @internal
*/
final class EncapsulatedStringAnalyzer
{
private const MAX_LITERALS = 500;

public static function analyze(
StatementsAnalyzer $statements_analyzer,
PhpParser\Node\Scalar\Encapsed $stmt,
Context $context
): 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) {
return false;
}

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,
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -115,21 +173,26 @@ 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) {
$stmt_type = new Union(
[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()],
Expand All @@ -152,4 +215,28 @@ public static function analyze(

return true;
}

/**
* @param string[] $literal_strings
* @return non-empty-array<string>
*/
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;
}
}
2 changes: 1 addition & 1 deletion src/Psalm/Storage/ClassConstantStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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};";
}
}
Expand Down
83 changes: 83 additions & 0 deletions tests/BinaryOperationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,48 @@ function foo(string $s1): string {
return "Hello $s1 $s2";
}',
],
'encapsedStringIncludingLiterals3' => [
'code' => '<?php
$foo = rand(0, 1) ? "" : random_bytes(1);
$interpolated = "hello {$foo} bar";',
'assertions' => ['$interpolated===' => "non-falsy-string"],
],
'encapsedStringWithoutLiterals1' => [
'code' => '<?php
/**
* @var non-falsy-string $a
* @var non-falsy-string $b
*/
$interpolated = "{$a}{$b}";',
'assertions' => ['$interpolated===' => "non-falsy-string"],
],
'encapsedStringWithoutLiterals2' => [
'code' => '<?php
/**
* @var string $a
* @var non-empty-string $b
*/
$interpolated = "{$a}{$b}";',
'assertions' => ['$interpolated===' => "non-empty-string"],
],
'encapsedStringWithoutLiterals3' => [
'code' => '<?php
/**
* @var non-empty-string $a
* @var non-falsy-string $b
*/
$interpolated = "{$a}{$b}";',
'assertions' => ['$interpolated===' => "non-falsy-string"],
],
'encapsedStringWithoutLiterals4' => [
'code' => '<?php
/**
* @var string $a
* @var non-falsy-string $b
*/
$interpolated = "{$a}{$b}";',
'assertions' => ['$interpolated===' => "non-falsy-string"],
],
'encapsedStringIsInferredAsLiteral' => [
'code' => '<?php
$int = 1;
Expand Down Expand Up @@ -935,6 +977,39 @@ function foo(int $s1): string {
return "Hello $s1 $s2";
}',
],
'encapsedWithUnionLiteralsKeepsLiteral' => [
'code' => '<?php
$foo = rand(0, 1) ? 0 : 2;
$encapsed = "{$foo}";',
'assertions' => ['$encapsed===' => "'0'|'2'"],
],
'encapsedWithUnionLiteralsKeepsLiteral2' => [
'code' => '<?php
/**
* @var "a"|"b" $a
* @var "hello"|"world"|"bye" $b
*/
$encapsed = "X{$a}Y{$b}Z";',
'assertions' => ['$encapsed===' => "'XaYbyeZ'|'XaYhelloZ'|'XaYworldZ'|'XbYbyeZ'|'XbYhelloZ'|'XbYworldZ'"],
],
'encapsedWithIntsKeepsLiteral' => [
'code' => '<?php
/**
* @var "a"|"b" $a
* @var 0|1|2 $b
*/
$encapsed = "{$a}{$b}";',
'assertions' => ['$encapsed===' => "'a0'|'a1'|'a2'|'b0'|'b1'|'b2'"],
],
'encapsedWithIntRangeKeepsLiteral' => [
'code' => '<?php
/**
* @var "a"|"b" $a
* @var int<0, 2> $b
*/
$encapsed = "{$a}{$b}";',
'assertions' => ['$encapsed===' => "'a0'|'a1'|'a2'|'b0'|'b1'|'b2'"],
],
'NumericStringIncrement' => [
'code' => '<?php
function scope(array $a): int|float {
Expand Down Expand Up @@ -1218,6 +1293,14 @@ function foo(string $s1, string $s2): string {
}',
'error_message' => 'LessSpecificReturnStatement',
],
'encapsedMixedIssue10942' => [
'code' => '<?php
/**
* @var mixed $y
*/
$z = "hello {$y} world";',
'error_message' => 'MixedOperand',
],
];
}
}
2 changes: 1 addition & 1 deletion tests/SwitchTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/TypeReconciliation/RedundantConditionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {}
}',
Expand Down
2 changes: 1 addition & 1 deletion tests/UnusedVariableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
Loading