1
0
mirror of https://github.com/danog/psalm.git synced 2025-01-22 05:41:20 +01:00

Merge pull request #8164 from AndrolGenhald/encapsed-literal-strings

Encapsed literal strings
This commit is contained in:
orklah 2022-06-26 12:52:33 +02:00 committed by GitHub
commit b3038f0936
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 232 additions and 73 deletions

View File

@ -3,6 +3,7 @@
namespace Psalm\Internal\Analyzer\Statements\Expression;
use PhpParser;
use PhpParser\Node\Expr\BinaryOp;
use PhpParser\Node\Expr\BinaryOp\Equal;
use PhpParser\Node\Expr\BinaryOp\Greater;
use PhpParser\Node\Expr\BinaryOp\GreaterOrEqual;
@ -103,6 +104,7 @@ class AssertionFinder
'is_iterable' => ['iterable'],
'is_countable' => ['countable'],
];
/**
* Gets all the type assertions in a conditional
*
@ -1499,50 +1501,35 @@ class AssertionFinder
PhpParser\Node\Expr\BinaryOp $conditional,
?int &$min_count
) {
$left_count = $conditional->left instanceof PhpParser\Node\Expr\FuncCall
if ($conditional->left instanceof PhpParser\Node\Expr\FuncCall
&& $conditional->left->name instanceof PhpParser\Node\Name
&& strtolower($conditional->left->name->parts[0]) === 'count'
&& $conditional->left->getArgs();
$operator_greater_than_or_equal =
$conditional instanceof PhpParser\Node\Expr\BinaryOp\Greater
|| $conditional instanceof PhpParser\Node\Expr\BinaryOp\GreaterOrEqual;
if ($left_count
&& $conditional->right instanceof PhpParser\Node\Scalar\LNumber
&& $operator_greater_than_or_equal
&& $conditional->right->value >= (
$conditional instanceof PhpParser\Node\Expr\BinaryOp\Greater
? 0
: 1
)
&& $conditional->left->getArgs()
&& ($conditional instanceof BinaryOp\Greater || $conditional instanceof BinaryOp\GreaterOrEqual)
) {
$min_count = $conditional->right->value +
($conditional instanceof PhpParser\Node\Expr\BinaryOp\Greater ? 1 : 0);
return self::ASSIGNMENT_TO_RIGHT;
}
$right_count = $conditional->right instanceof PhpParser\Node\Expr\FuncCall
$assignment_to = self::ASSIGNMENT_TO_RIGHT;
$compare_to = $conditional->right;
$comparison_adjustment = $conditional instanceof BinaryOp\Greater ? 1 : 0;
} elseif ($conditional->right instanceof PhpParser\Node\Expr\FuncCall
&& $conditional->right->name instanceof PhpParser\Node\Name
&& strtolower($conditional->right->name->parts[0]) === 'count'
&& $conditional->right->getArgs();
$operator_less_than_or_equal =
$conditional instanceof PhpParser\Node\Expr\BinaryOp\Smaller
|| $conditional instanceof PhpParser\Node\Expr\BinaryOp\SmallerOrEqual;
if ($right_count
&& $conditional->left instanceof PhpParser\Node\Scalar\LNumber
&& $operator_less_than_or_equal
&& $conditional->left->value >= (
$conditional instanceof PhpParser\Node\Expr\BinaryOp\Smaller ? 0 : 1
)
&& $conditional->right->getArgs()
&& ($conditional instanceof BinaryOp\Smaller || $conditional instanceof BinaryOp\SmallerOrEqual)
) {
$min_count = $conditional->left->value +
($conditional instanceof PhpParser\Node\Expr\BinaryOp\Smaller ? 1 : 0);
$assignment_to = self::ASSIGNMENT_TO_LEFT;
$compare_to = $conditional->left;
$comparison_adjustment = $conditional instanceof BinaryOp\Smaller ? 1 : 0;
} else {
return false;
}
return self::ASSIGNMENT_TO_LEFT;
// TODO get node type provider here somehow and check literal ints and int ranges
if ($compare_to instanceof PhpParser\Node\Scalar\LNumber
&& $compare_to->value > (-1 * $comparison_adjustment)
) {
$min_count = $compare_to->value + $comparison_adjustment;
return $assignment_to;
}
return false;

View File

@ -52,6 +52,8 @@ use function strlen;
*/
class ConcatAnalyzer
{
private const MAX_LITERALS = 64;
/**
* @param Union|null $result_type
*/
@ -155,39 +157,35 @@ class ConcatAnalyzer
self::analyzeOperand($statements_analyzer, $left, $left_type, 'Left', $context);
self::analyzeOperand($statements_analyzer, $right, $right_type, 'Right', $context);
// If one of the types is a single int or string literal, and the other
// type is all string or int literals, combine them into new literal(s).
// If both types are specific literals, combine them into new literals
$literal_concat = false;
if (($left_type->allStringLiterals() || $left_type->allIntLiterals())
&& ($right_type->allStringLiterals() || $right_type->allIntLiterals())
) {
$literal_concat = true;
$result_type_parts = [];
if ($left_type->allSpecificLiterals() && $right_type->allSpecificLiterals()) {
$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) {
$literal_concat = true;
$result_type_parts = [];
foreach ($left_type->getAtomicTypes() as $left_type_part) {
assert($left_type_part instanceof TLiteralString || $left_type_part instanceof TLiteralInt);
foreach ($right_type->getAtomicTypes() as $right_type_part) {
assert($right_type_part instanceof TLiteralString || $right_type_part instanceof TLiteralInt);
$literal = $left_type_part->value . $right_type_part->value;
if (strlen($literal) >= $config->max_string_length) {
// Literal too long, use non-literal type instead
$literal_concat = false;
break 2;
foreach ($left_type->getAtomicTypes() as $left_type_part) {
foreach ($right_type->getAtomicTypes() 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
$literal_concat = false;
break 2;
}
$result_type_parts[] = new TLiteralString($literal);
}
$result_type_parts[] = new TLiteralString($literal);
}
}
if (!empty($result_type_parts)) {
if ($literal_concat && count($result_type_parts) < 64) {
if ($literal_concat) {
assert(count($result_type_parts) === $combinations);
assert(count($result_type_parts) !== 0); // #8163
$result_type = new Union($result_type_parts);
} else {
$result_type = new Union([new TNonEmptyNonspecificLiteralString]);
}
return;
}
}

View File

@ -28,6 +28,7 @@ use Psalm\Type\Atomic\TFloat;
use Psalm\Type\Atomic\TInt;
use Psalm\Type\Atomic\TKeyedArray;
use Psalm\Type\Atomic\TList;
use Psalm\Type\Atomic\TLiteralFloat;
use Psalm\Type\Atomic\TLiteralInt;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TMixed;
@ -327,7 +328,7 @@ class CastAnalyzer
|| $atomic_type instanceof TInt
|| $atomic_type instanceof TNumeric
) {
if ($atomic_type instanceof TLiteralInt) {
if ($atomic_type instanceof TLiteralInt || $atomic_type instanceof TLiteralFloat) {
$castable_types[] = new TLiteralString((string) $atomic_type->value);
} elseif ($atomic_type instanceof TNonspecificLiteralInt) {
$castable_types[] = new TNonspecificLiteralString();

View File

@ -3,6 +3,7 @@
namespace Psalm\Internal\Analyzer\Statements\Expression;
use PhpParser;
use PhpParser\Node\Scalar\EncapsedStringPart;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
@ -10,10 +11,16 @@ use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Internal\DataFlow\DataFlowNode;
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\TNonspecificLiteralInt;
use Psalm\Type\Atomic\TNonspecificLiteralString;
use Psalm\Type\Union;
use function assert;
use function in_array;
class EncapsulatedStringAnalyzer
@ -29,20 +36,16 @@ class EncapsulatedStringAnalyzer
$all_literals = true;
foreach ($stmt->parts as $part) {
if ($part instanceof PhpParser\Node\Scalar\EncapsedStringPart
&& $part->value
) {
$non_empty = true;
}
$literal_string = "";
foreach ($stmt->parts as $part) {
if (ExpressionAnalyzer::analyze($statements_analyzer, $part, $context) === false) {
return false;
}
$part_type = $statements_analyzer->node_data->getType($part);
if ($part_type) {
if ($part_type !== null) {
$casted_part_type = CastAnalyzer::castStringAttempt(
$statements_analyzer,
$context,
@ -52,6 +55,28 @@ class EncapsulatedStringAnalyzer
if (!$casted_part_type->allLiterals()) {
$all_literals = false;
} elseif (!$non_empty) {
// Check if all literals are nonempty
$non_empty = true;
foreach ($casted_part_type->getAtomicTypes() as $atomic_literal) {
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 ($literal_string !== null) {
if ($casted_part_type->isSingleLiteral()) {
$literal_string .= $casted_part_type->getSingleLiteral()->value;
} else {
$literal_string = null;
}
}
if ($statements_analyzer->data_flow_graph
@ -82,16 +107,30 @@ class EncapsulatedStringAnalyzer
}
}
}
} elseif ($part instanceof EncapsedStringPart) {
if ($literal_string !== null) {
$literal_string .= $part->value;
}
$non_empty = $non_empty || $part->value !== "";
} else {
$all_literals = false;
$literal_string = null;
}
}
if ($non_empty) {
if ($all_literals) {
if ($literal_string !== null) {
$new_type = Type::getString($literal_string);
} elseif ($all_literals) {
$new_type = new Union([new TNonEmptyNonspecificLiteralString()]);
} else {
$new_type = new Union([new TNonEmptyString()]);
}
} elseif ($all_literals) {
$new_type = new Union([new TNonspecificLiteralString()]);
}
if (isset($new_type)) {
assert($new_type instanceof Union);
$new_type->parent_nodes = $stmt_type->parent_nodes;
$stmt_type = $new_type;
}

View File

@ -92,7 +92,7 @@ class FilterVarReturnTypeProvider implements FunctionReturnTypeProviderInterface
if (isset($atomic_type->properties['options'])
&& $atomic_type->properties['options']->hasArray()
&& ($options_array = $atomic_type->properties['options']->getAtomicTypes()['array'])
&& ($options_array = $atomic_type->properties['options']->getAtomicTypes()['array'] ?? null)
&& $options_array instanceof TKeyedArray
&& isset($options_array->properties['default'])
) {

View File

@ -7,6 +7,9 @@ namespace Psalm\Type\Atomic;
*/
class TFalse extends TBool
{
/** @var false */
public $value = false;
public function __toString(): string
{
return 'false';

View File

@ -7,6 +7,9 @@ namespace Psalm\Type\Atomic;
*/
class TTrue extends TBool
{
/** @var true */
public $value = true;
public function __toString(): string
{
return 'true';

View File

@ -270,6 +270,7 @@ class Union implements TypeNode
}
/**
* @psalm-mutation-free
* @return non-empty-array<string, Atomic>
*/
public function getAtomicTypes(): array
@ -1302,6 +1303,34 @@ class Union implements TypeNode
return true;
}
/**
* @psalm-assert-if-true array<
* array-key,
* TLiteralString|TLiteralInt|TLiteralFloat|TFalse|TTrue
* > $this->getAtomicTypes()
*/
public function allSpecificLiterals(): bool
{
foreach ($this->types as $atomic_key_type) {
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-assert-if-true array<
* array-key,
* TLiteralString|TLiteralInt|TLiteralFloat|TNonspecificLiteralString|TNonSpecificLiteralInt|TFalse|TTrue
* > $this->getAtomicTypes()
*/
public function allLiterals(): bool
{
foreach ($this->types as $atomic_key_type) {
@ -1329,6 +1358,32 @@ class Union implements TypeNode
|| isset($this->types['true']);
}
public function isSingleLiteral(): bool
{
return count($this->types) === 1
&& count($this->literal_int_types)
+ count($this->literal_string_types)
+ count($this->literal_float_types) === 1
;
}
/**
* @return TLiteralInt|TLiteralString|TLiteralFloat
*/
public function getSingleLiteral()
{
if (!$this->isSingleLiteral()) {
throw new InvalidArgumentException("Not a single literal");
}
return ($literal = reset($this->literal_int_types)) !== false
? $literal
: (($literal = reset($this->literal_string_types)) !== false
? $literal
: reset($this->literal_float_types))
;
}
public function hasLiteralString(): bool
{
return count($this->literal_string_types) > 0;

View File

@ -686,6 +686,50 @@ class BinaryOperationTest extends TestCase
return "Hello $s1 $s2";
}',
],
'encapsedStringIsInferredAsLiteral' => [
'<?php
$int = 1;
$float = 2.3;
$string = "foobar";
$interpolated = "{$int}{$float}{$string}";
',
'assertions' => ['$interpolated===' => '"12.3foobar"'],
],
'concatenatedStringIsInferredAsLiteral' => [
'<?php
$int = 1;
$float = 2.3;
$string = "foobar";
$concatenated = $int . $float . $string;
',
'assertions' => ['$concatenated===' => '"12.3foobar"'],
],
'encapsedNonEmptyNonSpecificLiteralString' => [
'<?php
/** @var non-empty-literal-string */
$string = "foobar";
$interpolated = "$string";
',
'assertions' => ['$interpolated===' => 'non-empty-literal-string'],
],
'concatenatedNonEmptyNonSpecificLiteralString' => [
'<?php
/** @var non-empty-literal-string */
$string = "foobar";
$concatenated = $string . "";
',
'assertions' => ['$concatenated===' => 'non-empty-literal-string'],
],
'encapsedPossiblyEmptyLiteralString' => [
'<?php
/** @var "foo"|"" */
$foo = "";
/** @var "bar"|"" */
$bar = "";
$interpolated = "{$foo}{$bar}";
',
'assertions' => ['$interpolated===' => 'literal-string'],
],
'literalIntConcatCreatesLiteral' => [
'<?php
/**

View File

@ -418,6 +418,35 @@ class EmptyTest extends TestCase
if (empty($arr["a"])) {}
}'
],
'SKIPPED-countWithLiteralIntVariable' => [ // #8163
'<?php
$c = 1;
/** @var list<int> */
$arr = [1];
assert(count($arr) === $c);
',
'assertions' => ['$arr===' => 'non-empty-list<int>'],
],
'SKIPPED-countWithIntRange' => [ // #8163
'<?php
/** @var int<1, max> */
$c = 1;
/** @var list<int> */
$arr = [1];
assert(count($arr) === $c);
',
'assertions' => ['$arr===' => 'non-empty-list<int>'],
],
'SKIPPED-countEmptyWithIntRange' => [ // #8163
'<?php
/** @var int<0, max> */
$c = 1;
/** @var list<int> */
$arr = [1];
assert(count($arr) === $c);
',
'assertions' => ['$arr===' => 'list<int>'],
],
];
}