mirror of
https://github.com/danog/psalm.git
synced 2024-11-26 20:34:47 +01:00
ConcatAnalyzer improvements and non-falsy-string fixes. (#5544)
* ConcatAnalyzer improvements. Deduplicate code. Improve type inference. Allow literal type inference when only one side has multiple types (fixes #5483). Fix invalid type inference with negative int as right operand. * Fix inference to be lowercase-string when concatenating int. * Fix TNonEmptyLowercaseString to not be subtype of TNonFalsyString. '0' is a non-empty-lowercase-string that is falsy. * Fix other issues with non-falsy-string. * Nest ands and ors Co-authored-by: Matthew Brown <github@muglug.com>
This commit is contained in:
parent
fe97aa0722
commit
d022910599
@ -157,7 +157,7 @@ class StaticPropertyAssignmentAnalyzer
|
||||
|
||||
$file_manipulations = [];
|
||||
|
||||
if (strtolower($new_fq_class_name) !== strtolower($old_declaring_fq_class_name)) {
|
||||
if (strtolower($new_fq_class_name) !== $old_declaring_fq_class_name) {
|
||||
$file_manipulations[] = new \Psalm\FileManipulation(
|
||||
(int) $stmt->class->getAttribute('startFilePos'),
|
||||
(int) $stmt->class->getAttribute('endFilePos') + 1,
|
||||
|
@ -8,6 +8,7 @@ use Psalm\Internal\Codebase\VariableUseGraph;
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Config;
|
||||
use Psalm\Context;
|
||||
use Psalm\Internal\Type\Comparator\UnionTypeComparator;
|
||||
use Psalm\Issue\FalseOperand;
|
||||
use Psalm\Issue\ImplicitToStringCast;
|
||||
use Psalm\Issue\ImpureMethodCall;
|
||||
@ -19,8 +20,10 @@ use Psalm\Issue\PossiblyInvalidOperand;
|
||||
use Psalm\Issue\PossiblyNullOperand;
|
||||
use Psalm\IssueBuffer;
|
||||
use Psalm\Type;
|
||||
use Psalm\Type\Atomic\TLiteralInt;
|
||||
use Psalm\Type\Atomic\TLiteralString;
|
||||
use Psalm\Type\Atomic\TNamedObject;
|
||||
use function strtolower;
|
||||
use function assert;
|
||||
use function strlen;
|
||||
use function array_merge;
|
||||
use function count;
|
||||
@ -135,419 +138,275 @@ class ConcatAnalyzer
|
||||
$codebase->analyzer->incrementNonMixedCount($statements_analyzer->getFilePath());
|
||||
}
|
||||
|
||||
if ($left_type->isNull()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new NullOperand(
|
||||
'Cannot concatenate with a ' . $left_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
self::analyzeOperand($statements_analyzer, $left, $left_type, 'Left', $context);
|
||||
self::analyzeOperand($statements_analyzer, $right, $right_type, 'Right', $context);
|
||||
|
||||
return;
|
||||
}
|
||||
// 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).
|
||||
$literal_concat = false;
|
||||
if ((($left_type->isSingleStringLiteral() || $left_type->isSingleIntLiteral())
|
||||
&& ($right_type->allStringLiterals() || $right_type->allIntLiterals()))
|
||||
|| (($right_type->isSingleStringLiteral() || $right_type->isSingleIntLiteral())
|
||||
&& ($left_type->allStringLiterals() || $left_type->allIntLiterals()))
|
||||
) {
|
||||
$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;
|
||||
}
|
||||
|
||||
if ($right_type->isNull()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new NullOperand(
|
||||
'Cannot concatenate with a ' . $right_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($left_type->isFalse()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new FalseOperand(
|
||||
'Cannot concatenate with a ' . $left_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($right_type->isFalse()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new FalseOperand(
|
||||
'Cannot concatenate with a ' . $right_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($left_type->isNullable() && !$left_type->ignore_nullable_issues) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyNullOperand(
|
||||
'Cannot concatenate with a possibly null ' . $left_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if ($right_type->isNullable() && !$right_type->ignore_nullable_issues) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyNullOperand(
|
||||
'Cannot concatenate with a possibly null ' . $right_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if ($left_type->isFalsable() && !$left_type->ignore_falsable_issues) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyFalseOperand(
|
||||
'Cannot concatenate with a possibly false ' . $left_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if ($right_type->isFalsable() && !$right_type->ignore_falsable_issues) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyFalseOperand(
|
||||
'Cannot concatenate with a possibly false ' . $right_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
$left_type_match = true;
|
||||
$right_type_match = true;
|
||||
|
||||
$has_valid_left_operand = false;
|
||||
$has_valid_right_operand = false;
|
||||
|
||||
$left_comparison_result = new \Psalm\Internal\Type\Comparator\TypeComparisonResult();
|
||||
$right_comparison_result = new \Psalm\Internal\Type\Comparator\TypeComparisonResult();
|
||||
|
||||
foreach ($left_type->getAtomicTypes() as $left_type_part) {
|
||||
if ($left_type_part instanceof Type\Atomic\TTemplateParam && !$left_type_part->as->isString()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new MixedOperand(
|
||||
'Left operand cannot be a non-string template param',
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
$result_type_parts[] = new Type\Atomic\TLiteralString($literal);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($left_type_part instanceof Type\Atomic\TNull || $left_type_part instanceof Type\Atomic\TFalse) {
|
||||
continue;
|
||||
if ($literal_concat && !empty($result_type_parts)) {
|
||||
$result_type = new Type\Union($result_type_parts);
|
||||
}
|
||||
}
|
||||
|
||||
$left_type_part_match = AtomicTypeComparator::isContainedBy(
|
||||
if (!$literal_concat) {
|
||||
$numeric_type = Type::getNumericString();
|
||||
$numeric_type->addType(new Type\Atomic\TInt());
|
||||
$numeric_type->addType(new Type\Atomic\TFloat());
|
||||
$left_is_numeric = UnionTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$left_type_part,
|
||||
new Type\Atomic\TString,
|
||||
false,
|
||||
false,
|
||||
$left_comparison_result
|
||||
$left_type,
|
||||
$numeric_type
|
||||
);
|
||||
$right_numeric_type = Type::getPositiveInt();
|
||||
$right_numeric_type->addType(new Type\Atomic\TLiteralInt(0));
|
||||
$right_is_numeric = UnionTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$right_type,
|
||||
$right_numeric_type
|
||||
);
|
||||
|
||||
$left_type_match = $left_type_match && $left_type_part_match;
|
||||
|
||||
$has_valid_left_operand = $has_valid_left_operand || $left_type_part_match;
|
||||
|
||||
if ($left_comparison_result->to_string_cast && $config->strict_binary_operands) {
|
||||
if (IssueBuffer::accepts(
|
||||
new ImplicitToStringCast(
|
||||
'Left side of concat op expects string, '
|
||||
. '\'' . $left_type . '\' provided with a __toString method',
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($left_type->getAtomicTypes() as $atomic_type) {
|
||||
if ($atomic_type instanceof TNamedObject) {
|
||||
$to_string_method_id = new \Psalm\Internal\MethodIdentifier(
|
||||
$atomic_type->value,
|
||||
'__tostring'
|
||||
);
|
||||
|
||||
if ($codebase->methods->methodExists(
|
||||
$to_string_method_id,
|
||||
$context->calling_method_id,
|
||||
$codebase->collect_locations
|
||||
? new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
: null,
|
||||
!$context->collect_initializations
|
||||
&& !$context->collect_mutations
|
||||
? $statements_analyzer
|
||||
: null,
|
||||
$statements_analyzer->getFilePath()
|
||||
)) {
|
||||
try {
|
||||
$storage = $codebase->methods->getStorage($to_string_method_id);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($context->mutation_free && !$storage->mutation_free) {
|
||||
if (IssueBuffer::accepts(
|
||||
new ImpureMethodCall(
|
||||
'Cannot call a possibly-mutating method '
|
||||
. $atomic_type->value . '::__toString from a pure context',
|
||||
new CodeLocation($statements_analyzer, $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} elseif ($statements_analyzer->getSource()
|
||||
instanceof \Psalm\Internal\Analyzer\FunctionLikeAnalyzer
|
||||
&& $statements_analyzer->getSource()->track_mutations
|
||||
) {
|
||||
$statements_analyzer->getSource()->inferred_has_mutation = true;
|
||||
$statements_analyzer->getSource()->inferred_impure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($right_type->getAtomicTypes() as $right_type_part) {
|
||||
if ($right_type_part instanceof Type\Atomic\TTemplateParam && !$right_type_part->as->isString()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new MixedOperand(
|
||||
'Right operand cannot be a non-string template param',
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($right_type_part instanceof Type\Atomic\TNull || $right_type_part instanceof Type\Atomic\TFalse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$right_type_part_match = AtomicTypeComparator::isContainedBy(
|
||||
$lowercase_type = clone $numeric_type;
|
||||
$lowercase_type->addType(new Type\Atomic\TLowercaseString());
|
||||
$left_is_lowercase = UnionTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$right_type_part,
|
||||
new Type\Atomic\TString,
|
||||
false,
|
||||
false,
|
||||
$right_comparison_result
|
||||
$left_type,
|
||||
$lowercase_type
|
||||
);
|
||||
$right_is_lowercase = UnionTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$right_type,
|
||||
$lowercase_type
|
||||
);
|
||||
|
||||
$right_type_match = $right_type_match && $right_type_part_match;
|
||||
|
||||
$has_valid_right_operand = $has_valid_right_operand || $right_type_part_match;
|
||||
|
||||
if ($right_comparison_result->to_string_cast && $config->strict_binary_operands) {
|
||||
if (IssueBuffer::accepts(
|
||||
new ImplicitToStringCast(
|
||||
'Right side of concat op expects string, '
|
||||
. '\'' . $right_type . '\' provided with a __toString method',
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
$left_is_non_empty = UnionTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$left_type,
|
||||
Type::getNonEmptyString()
|
||||
);
|
||||
$right_is_non_empty = UnionTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$right_type,
|
||||
Type::getNonEmptyString()
|
||||
);
|
||||
if ($left_is_numeric && $right_is_numeric) {
|
||||
$result_type = Type::getNumericString();
|
||||
} elseif ($left_is_lowercase && $right_is_lowercase) {
|
||||
if ($left_is_non_empty || $right_is_non_empty) {
|
||||
$result_type = Type::getNonEmptyLowercaseString();
|
||||
} else {
|
||||
$result_type = Type::getLowercaseString();
|
||||
}
|
||||
} elseif ($left_is_non_empty || $right_is_non_empty) {
|
||||
$result_type = Type::getNonEmptyString();
|
||||
} else {
|
||||
$result_type = Type::getString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function analyzeOperand(
|
||||
StatementsAnalyzer $statements_analyzer,
|
||||
PhpParser\Node\Expr $operand,
|
||||
Type\Union $operand_type,
|
||||
string $side,
|
||||
Context $context
|
||||
): void {
|
||||
$codebase = $statements_analyzer->getCodebase();
|
||||
$config = Config::getInstance();
|
||||
|
||||
if ($operand_type->isNull()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new NullOperand(
|
||||
'Cannot concatenate with a ' . $operand_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operand_type->isFalse()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new FalseOperand(
|
||||
'Cannot concatenate with a ' . $operand_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operand_type->isNullable() && !$operand_type->ignore_nullable_issues) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyNullOperand(
|
||||
'Cannot concatenate with a possibly null ' . $operand_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if ($operand_type->isFalsable() && !$operand_type->ignore_falsable_issues) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyFalseOperand(
|
||||
'Cannot concatenate with a possibly false ' . $operand_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
$operand_type_match = true;
|
||||
$has_valid_operand = false;
|
||||
$comparison_result = new \Psalm\Internal\Type\Comparator\TypeComparisonResult();
|
||||
|
||||
foreach ($operand_type->getAtomicTypes() as $operand_type_part) {
|
||||
if ($operand_type_part instanceof Type\Atomic\TTemplateParam && !$operand_type_part->as->isString()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new MixedOperand(
|
||||
"$side operand cannot be a non-string template param",
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
foreach ($right_type->getAtomicTypes() as $atomic_type) {
|
||||
if ($atomic_type instanceof TNamedObject) {
|
||||
$to_string_method_id = new \Psalm\Internal\MethodIdentifier(
|
||||
$atomic_type->value,
|
||||
'__tostring'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($codebase->methods->methodExists(
|
||||
$to_string_method_id,
|
||||
$context->calling_method_id,
|
||||
$codebase->collect_locations
|
||||
? new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
: null,
|
||||
!$context->collect_initializations
|
||||
&& !$context->collect_mutations
|
||||
? $statements_analyzer
|
||||
: null,
|
||||
$statements_analyzer->getFilePath()
|
||||
)) {
|
||||
try {
|
||||
$storage = $codebase->methods->getStorage($to_string_method_id);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
continue;
|
||||
}
|
||||
if ($operand_type_part instanceof Type\Atomic\TNull || $operand_type_part instanceof Type\Atomic\TFalse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($context->mutation_free && !$storage->mutation_free) {
|
||||
if (IssueBuffer::accepts(
|
||||
new ImpureMethodCall(
|
||||
'Cannot call a possibly-mutating method '
|
||||
. $atomic_type->value . '::__toString from a pure context',
|
||||
new CodeLocation($statements_analyzer, $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} elseif ($statements_analyzer->getSource()
|
||||
instanceof \Psalm\Internal\Analyzer\FunctionLikeAnalyzer
|
||||
&& $statements_analyzer->getSource()->track_mutations
|
||||
) {
|
||||
$statements_analyzer->getSource()->inferred_has_mutation = true;
|
||||
$statements_analyzer->getSource()->inferred_impure = true;
|
||||
}
|
||||
$operand_type_part_match = AtomicTypeComparator::isContainedBy(
|
||||
$codebase,
|
||||
$operand_type_part,
|
||||
new Type\Atomic\TString,
|
||||
false,
|
||||
false,
|
||||
$comparison_result
|
||||
);
|
||||
|
||||
$operand_type_match = $operand_type_match && $operand_type_part_match;
|
||||
|
||||
$has_valid_operand = $has_valid_operand || $operand_type_part_match;
|
||||
|
||||
if ($comparison_result->to_string_cast && $config->strict_binary_operands) {
|
||||
if (IssueBuffer::accepts(
|
||||
new ImplicitToStringCast(
|
||||
"$side side of concat op expects string, '$operand_type' provided with a __toString method",
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($operand_type->getAtomicTypes() as $atomic_type) {
|
||||
if ($atomic_type instanceof TNamedObject) {
|
||||
$to_string_method_id = new \Psalm\Internal\MethodIdentifier(
|
||||
$atomic_type->value,
|
||||
'__tostring'
|
||||
);
|
||||
|
||||
if ($codebase->methods->methodExists(
|
||||
$to_string_method_id,
|
||||
$context->calling_method_id,
|
||||
$codebase->collect_locations
|
||||
? new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
: null,
|
||||
!$context->collect_initializations
|
||||
&& !$context->collect_mutations
|
||||
? $statements_analyzer
|
||||
: null,
|
||||
$statements_analyzer->getFilePath()
|
||||
)) {
|
||||
try {
|
||||
$storage = $codebase->methods->getStorage($to_string_method_id);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$left_type_match
|
||||
&& (!$left_comparison_result->scalar_type_match_found || $config->strict_binary_operands)
|
||||
) {
|
||||
if ($has_valid_left_operand) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyInvalidOperand(
|
||||
'Cannot concatenate with a ' . $left_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} else {
|
||||
if (IssueBuffer::accepts(
|
||||
new InvalidOperand(
|
||||
'Cannot concatenate with a ' . $left_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $left)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$right_type_match
|
||||
&& (!$right_comparison_result->scalar_type_match_found || $config->strict_binary_operands)
|
||||
) {
|
||||
if ($has_valid_right_operand) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyInvalidOperand(
|
||||
'Cannot concatenate with a ' . $right_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} else {
|
||||
if (IssueBuffer::accepts(
|
||||
new InvalidOperand(
|
||||
'Cannot concatenate with a ' . $right_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $right)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
if ($context->mutation_free && !$storage->mutation_free) {
|
||||
if (IssueBuffer::accepts(
|
||||
new ImpureMethodCall(
|
||||
'Cannot call a possibly-mutating method '
|
||||
. $atomic_type->value . '::__toString from a pure context',
|
||||
new CodeLocation($statements_analyzer, $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} elseif ($statements_analyzer->getSource()
|
||||
instanceof \Psalm\Internal\Analyzer\FunctionLikeAnalyzer
|
||||
&& $statements_analyzer->getSource()->track_mutations
|
||||
) {
|
||||
$statements_analyzer->getSource()->inferred_has_mutation = true;
|
||||
$statements_analyzer->getSource()->inferred_impure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When concatenating two known string literals (with only one possibility),
|
||||
// put the concatenated string into $result_type
|
||||
if ($left_type && $right_type && $left_type->isSingleStringLiteral() && $right_type->isSingleStringLiteral()) {
|
||||
$literal = $left_type->getSingleStringLiteral()->value . $right_type->getSingleStringLiteral()->value;
|
||||
if (strlen($literal) <= 1000) {
|
||||
// Limit these to 1000 bytes to avoid extremely large union types from repeated concatenations, etc
|
||||
$result_type = Type::getString($literal);
|
||||
}
|
||||
} elseif ($left_type && $right_type && $left_type->isSingleIntLiteral() && $right_type->isSingleIntLiteral()) {
|
||||
$literal = $left_type->getSingleIntLiteral()->value . $right_type->getSingleIntLiteral()->value;
|
||||
if (strlen($literal) <= 1000) {
|
||||
// Limit these to 1000 bytes to avoid extremely large union types from repeated concatenations, etc
|
||||
$result_type = Type::getString($literal);
|
||||
}
|
||||
} elseif ($left_type && $right_type &&
|
||||
$left_type->isSingle() && $right_type->isSingle() &&
|
||||
$left_type->isInt() && $right_type->isInt()
|
||||
if (!$operand_type_match
|
||||
&& (!$comparison_result->scalar_type_match_found || $config->strict_binary_operands)
|
||||
) {
|
||||
$result_type = Type::getNumericString();
|
||||
} else {
|
||||
if ($left_type
|
||||
&& $right_type
|
||||
) {
|
||||
$left_type_literal_value = $left_type->isSingleStringLiteral()
|
||||
? $left_type->getSingleStringLiteral()->value
|
||||
: null;
|
||||
|
||||
$right_type_literal_value = $right_type->isSingleStringLiteral()
|
||||
? $right_type->getSingleStringLiteral()->value
|
||||
: null;
|
||||
|
||||
if (($left_type->getId() === 'lowercase-string'
|
||||
|| $left_type->getId() === 'non-empty-lowercase-string'
|
||||
|| $left_type->isInt()
|
||||
|| ($left_type_literal_value !== null
|
||||
&& strtolower($left_type_literal_value) === $left_type_literal_value))
|
||||
&& ($right_type->getId() === 'lowercase-string'
|
||||
|| $right_type->getId() === 'non-empty-lowercase-string'
|
||||
|| $right_type->isInt()
|
||||
|| ($right_type_literal_value !== null
|
||||
&& strtolower($right_type_literal_value) === $right_type_literal_value))
|
||||
) {
|
||||
if ($left_type->getId() === 'non-empty-lowercase-string'
|
||||
|| $left_type->isInt()
|
||||
|| ($left_type_literal_value !== null
|
||||
&& strtolower($left_type_literal_value) === $left_type_literal_value)
|
||||
|| $right_type->getId() === 'non-empty-lowercase-string'
|
||||
|| $right_type->isInt()
|
||||
|| ($right_type_literal_value !== null
|
||||
&& strtolower($right_type_literal_value) === $right_type_literal_value)
|
||||
) {
|
||||
$result_type = new Type\Union([new Type\Atomic\TNonEmptyLowercaseString()]);
|
||||
} else {
|
||||
$result_type = new Type\Union([new Type\Atomic\TLowercaseString()]);
|
||||
}
|
||||
} elseif ($left_type->getId() === 'non-empty-string'
|
||||
|| $right_type->getId() === 'non-empty-string'
|
||||
|| $left_type_literal_value
|
||||
|| $right_type_literal_value
|
||||
) {
|
||||
$result_type = new Type\Union([new Type\Atomic\TNonEmptyString()]);
|
||||
if ($has_valid_operand) {
|
||||
if (IssueBuffer::accepts(
|
||||
new PossiblyInvalidOperand(
|
||||
'Cannot concatenate with a ' . $operand_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
} else {
|
||||
if (IssueBuffer::accepts(
|
||||
new InvalidOperand(
|
||||
'Cannot concatenate with a ' . $operand_type,
|
||||
new CodeLocation($statements_analyzer->getSource(), $operand)
|
||||
),
|
||||
$statements_analyzer->getSuppressedIssues()
|
||||
)) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -292,7 +292,7 @@ class StaticPropertyFetchAnalyzer
|
||||
|
||||
$file_manipulations = [];
|
||||
|
||||
if (strtolower($new_fq_class_name) !== strtolower($old_declaring_fq_class_name)) {
|
||||
if (strtolower($new_fq_class_name) !== $old_declaring_fq_class_name) {
|
||||
$file_manipulations[] = new \Psalm\FileManipulation(
|
||||
(int) $stmt->class->getAttribute('startFilePos'),
|
||||
(int) $stmt->class->getAttribute('endFilePos') + 1,
|
||||
|
@ -259,14 +259,14 @@ class ScalarTypeComparator
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($container_type_part instanceof TNonEmptyString
|
||||
if (get_class($container_type_part) === TNonEmptyString::class
|
||||
&& $input_type_part instanceof TNonFalsyString
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($container_type_part instanceof TNonFalsyString
|
||||
&& get_class($input_type_part) === TNonEmptyString::class
|
||||
&& $input_type_part instanceof TNonEmptyString
|
||||
) {
|
||||
if ($atomic_comparison_result) {
|
||||
$atomic_comparison_result->type_coerced = true;
|
||||
|
@ -1068,12 +1068,12 @@ class TypeCombiner
|
||||
|| get_class($type) === TNonFalsyString::class)
|
||||
&& get_class($combination->value_types['string']) === TNonEmptyLowercaseString::class
|
||||
) {
|
||||
$combination->value_types['string'] = $type;
|
||||
$combination->value_types['string'] = new TNonEmptyString();
|
||||
} elseif ((get_class($combination->value_types['string']) === TNonEmptyString::class
|
||||
|| get_class($combination->value_types['string']) === TNonFalsyString::class)
|
||||
&& get_class($type) === TNonEmptyLowercaseString::class
|
||||
) {
|
||||
//no-change
|
||||
$combination->value_types['string'] = new TNonEmptyString();
|
||||
} elseif (get_class($type) === TLowercaseString::class
|
||||
&& get_class($combination->value_types['string']) === TNonEmptyLowercaseString::class
|
||||
) {
|
||||
|
@ -28,8 +28,11 @@ use Psalm\Type\Atomic\TLiteralClassString;
|
||||
use Psalm\Type\Atomic\TLiteralFloat;
|
||||
use Psalm\Type\Atomic\TLiteralInt;
|
||||
use Psalm\Type\Atomic\TLiteralString;
|
||||
use Psalm\Type\Atomic\TLowercaseString;
|
||||
use Psalm\Type\Atomic\TMixed;
|
||||
use Psalm\Type\Atomic\TNamedObject;
|
||||
use Psalm\Type\Atomic\TNonEmptyLowercaseString;
|
||||
use Psalm\Type\Atomic\TNonEmptyString;
|
||||
use Psalm\Type\Atomic\TNull;
|
||||
use Psalm\Type\Atomic\TNumeric;
|
||||
use Psalm\Type\Atomic\TNumericString;
|
||||
@ -174,6 +177,13 @@ abstract class Type
|
||||
return $union;
|
||||
}
|
||||
|
||||
public static function getLowercaseString(): Union
|
||||
{
|
||||
$type = new TLowercaseString();
|
||||
|
||||
return new Union([$type]);
|
||||
}
|
||||
|
||||
public static function getPositiveInt(bool $from_calculation = false): Union
|
||||
{
|
||||
$union = new Union([new Type\Atomic\TPositiveInt()]);
|
||||
@ -182,6 +192,20 @@ abstract class Type
|
||||
return $union;
|
||||
}
|
||||
|
||||
public static function getNonEmptyLowercaseString(): Union
|
||||
{
|
||||
$type = new TNonEmptyLowercaseString();
|
||||
|
||||
return new Union([$type]);
|
||||
}
|
||||
|
||||
public static function getNonEmptyString(): Union
|
||||
{
|
||||
$type = new TNonEmptyString();
|
||||
|
||||
return new Union([$type]);
|
||||
}
|
||||
|
||||
public static function getNumeric(): Union
|
||||
{
|
||||
$type = new TNumeric;
|
||||
|
@ -4,7 +4,7 @@ namespace Psalm\Type\Atomic;
|
||||
/**
|
||||
* Denotes a non-empty-string where every character is lowercased. (which can also result from a `strtolower` call).
|
||||
*/
|
||||
class TNonEmptyLowercaseString extends TNonFalsyString
|
||||
class TNonEmptyLowercaseString extends TNonEmptyString
|
||||
{
|
||||
public function getKey(bool $include_extra = true): string
|
||||
{
|
||||
|
@ -650,6 +650,24 @@ class ArgTest extends TestCase
|
||||
',
|
||||
'error_message' => 'InvalidArgument',
|
||||
],
|
||||
'numericStringIsNotNonFalsy' => [
|
||||
'<?php
|
||||
/** @param non-falsy-string $arg */
|
||||
function foo(string $arg): string
|
||||
{
|
||||
return $arg;
|
||||
}
|
||||
|
||||
/** @return numeric-string */
|
||||
function bar(): string
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
foo(bar());
|
||||
',
|
||||
'error_message' => 'ArgumentTypeCoercion',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -166,11 +166,115 @@ class BinaryOperationTest extends TestCase
|
||||
],
|
||||
'concatenationWithTwoInt' => [
|
||||
'<?php
|
||||
/** @return numeric-string */
|
||||
/**
|
||||
* @param positive-int|0 $b
|
||||
* @return numeric-string
|
||||
*/
|
||||
function scope(int $a, int $b): string{
|
||||
return $a . $b;
|
||||
}',
|
||||
],
|
||||
'concatenateUnion' => [
|
||||
'<?php
|
||||
$arr = ["foobar" => false, "foobaz" => true, "barbaz" => true];
|
||||
$foo = random_int(0, 1) ? "foo" : "bar";
|
||||
$foo .= "baz";
|
||||
$val = $arr[$foo];
|
||||
',
|
||||
'assertions' => ['$val' => 'true'],
|
||||
],
|
||||
'concatenateLiteralIntAndString' => [
|
||||
'<?php
|
||||
$arr = ["foobar" => false, "foo123" => true];
|
||||
$foo = "foo";
|
||||
$foo .= 123;
|
||||
$val = $arr[$foo];
|
||||
',
|
||||
'assertions' => ['$val' => 'true'],
|
||||
],
|
||||
'concatenateNonEmptyResultsInNonEmpty' => [
|
||||
'<?php
|
||||
/** @param non-empty-lowercase-string $arg */
|
||||
function foobar($arg): string
|
||||
{
|
||||
return $arg;
|
||||
}
|
||||
|
||||
/** @var "a"|"b" */
|
||||
$foo = "a";
|
||||
/** @var "c"|"d" */
|
||||
$bar = "c";
|
||||
$baz = $foo . $bar;
|
||||
foobar($baz);
|
||||
',
|
||||
],
|
||||
'concatenateEmptyWithNonemptyCast' => [
|
||||
'<?php
|
||||
class A
|
||||
{
|
||||
/** @psalm-return non-empty-lowercase-string */
|
||||
public function __toString(): string
|
||||
{
|
||||
return "foo";
|
||||
}
|
||||
}
|
||||
|
||||
/** @param non-empty-lowercase-string $arg */
|
||||
function foo($arg): string
|
||||
{
|
||||
return $arg;
|
||||
}
|
||||
|
||||
$bar = new A();
|
||||
foo("" . $bar);
|
||||
',
|
||||
],
|
||||
'concatenateNegativeIntLeftSideIsNumeric' => [
|
||||
'<?php
|
||||
/**
|
||||
* @param numeric-string $bar
|
||||
* @return int
|
||||
*/
|
||||
function foo(string $bar): int
|
||||
{
|
||||
return (int) $bar;
|
||||
}
|
||||
|
||||
foo(foo("-123") . 456);
|
||||
',
|
||||
],
|
||||
'concatenateFloatWithInt' => [
|
||||
'<?php
|
||||
/**
|
||||
* @param numeric-string $bar
|
||||
* @return numeric-string
|
||||
*/
|
||||
function foo(string $bar): string
|
||||
{
|
||||
return $bar;
|
||||
}
|
||||
|
||||
foo(-123.456 . 789);
|
||||
',
|
||||
],
|
||||
'concatenateIntIsLowercase' => [
|
||||
'<?php
|
||||
/**
|
||||
* @param non-empty-lowercase-string $bar
|
||||
* @return non-empty-lowercase-string
|
||||
*/
|
||||
function foobar(string $bar): string
|
||||
{
|
||||
return $bar;
|
||||
}
|
||||
|
||||
/** @var lowercase-string */
|
||||
$foo = "abc";
|
||||
/** @var int */
|
||||
$bar = 123;
|
||||
foobar($foo . $bar);
|
||||
',
|
||||
],
|
||||
'possiblyInvalidAdditionOnBothSides' => [
|
||||
'<?php
|
||||
function foo(string $s) : int {
|
||||
@ -431,6 +535,21 @@ class BinaryOperationTest extends TestCase
|
||||
'error_levels' => [],
|
||||
'strict_mode' => true,
|
||||
],
|
||||
'concatenateNegativeIntRightSideIsNotNumeric' => [
|
||||
'<?php
|
||||
/**
|
||||
* @param numeric-string $bar
|
||||
* @return int
|
||||
*/
|
||||
function foo(string $bar): int
|
||||
{
|
||||
return (int) $bar;
|
||||
}
|
||||
|
||||
foo(foo("123") . foo("-456"));
|
||||
',
|
||||
'error_message' => 'ArgumentTypeCoercion',
|
||||
],
|
||||
'addArrayToNumber' => [
|
||||
'<?php
|
||||
$a = [1] + 1;',
|
||||
|
@ -1553,6 +1553,15 @@ class FunctionCallTest extends TestCase
|
||||
'$a' => 'DateTimeImmutable|float',
|
||||
],
|
||||
],
|
||||
'strtolowerEmptiness' => [
|
||||
'<?php
|
||||
/** @param non-empty-string $s */
|
||||
function foo(string $s) : void {
|
||||
$s = strtolower($s);
|
||||
|
||||
foo($s);
|
||||
}',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@ -2037,16 +2046,6 @@ class FunctionCallTest extends TestCase
|
||||
}',
|
||||
'error_message' => 'PossiblyUndefinedArrayOffset',
|
||||
],
|
||||
'strtolowerEmptiness' => [
|
||||
'<?php
|
||||
/** @param non-empty-string $s */
|
||||
function foo(string $s) : void {
|
||||
$s = strtolower($s);
|
||||
|
||||
if ($s) {}
|
||||
}',
|
||||
'error_message' => 'RedundantConditionGivenDocblockType',
|
||||
],
|
||||
'strposNoSetFirstParam' => [
|
||||
'<?php
|
||||
function sayHello(string $format): void {
|
||||
|
@ -714,6 +714,13 @@ class TypeCombinationTest extends TestCase
|
||||
'non-empty-string'
|
||||
]
|
||||
],
|
||||
'combineNonEmptyLowercaseAndNonFalsyString' => [
|
||||
'non-empty-string',
|
||||
[
|
||||
'non-falsy-string',
|
||||
'non-empty-lowercase-string',
|
||||
]
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user