Skip to content
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
47 changes: 47 additions & 0 deletions src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType;
use PHPStan\Type\Accessory\AccessoryLiteralStringType;
use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
Expand All @@ -66,6 +67,7 @@
use PHPStan\Type\Constant\OversizedArrayBuilder;
use PHPStan\Type\ConstantScalarType;
use PHPStan\Type\ConstantTypeHelper;
use PHPStan\Type\DecimalIntegerStringHelper;
use PHPStan\Type\Enum\EnumCaseObjectType;
use PHPStan\Type\ErrorType;
use PHPStan\Type\FloatType;
Expand Down Expand Up @@ -595,6 +597,10 @@ public function resolveConcatType(Type $left, Type $right): Type
$accessoryTypes[] = new AccessoryUppercaseStringType();
}

if ($this->isConcatNonDecimalIntegerString($leftStringType, $rightStringType)) {
$accessoryTypes[] = new AccessoryDecimalIntegerStringType(inverse: true);
}

$leftNumericStringNonEmpty = TypeCombinator::remove($leftStringType, new ConstantStringType(''));
if ($leftNumericStringNonEmpty->isNumericString()->yes()) {
$validationCallback = $left->isInteger()->yes()
Expand Down Expand Up @@ -633,6 +639,47 @@ public function resolveConcatType(Type $left, Type $right): Type
return new StringType();
}

/**
* A decimal-int-string is made of digits with an optional leading `-` (and without
* redundant leading zeros). So when one of the operands is a known constant string
* that cannot occur at its side of such a string, the concatenation can never be one.
*
* This is only sound for constant operands: `non-decimal-int-string . int` is not
* a non-decimal-int-string, because `'-' . 1` is `'-1'`.
*/
private function isConcatNonDecimalIntegerString(Type $leftStringType, Type $rightStringType): bool
{
$leftConstantStrings = $leftStringType->getConstantStrings();
if (count($leftConstantStrings) > 0) {
$rightCanBeEmpty = !$rightStringType->isNonEmptyString()->yes();
$allDisqualify = true;
foreach ($leftConstantStrings as $leftConstantString) {
if (DecimalIntegerStringHelper::canStart($leftConstantString->getValue(), $rightCanBeEmpty)) {
$allDisqualify = false;
break;
}
}

if ($allDisqualify) {
return true;
}
}

$rightConstantStrings = $rightStringType->getConstantStrings();
if (count($rightConstantStrings) === 0) {
return false;
}

$leftCanBeEmpty = !$leftStringType->isNonEmptyString()->yes();
foreach ($rightConstantStrings as $rightConstantString) {
if (DecimalIntegerStringHelper::canEnd($rightConstantString->getValue(), $leftCanBeEmpty)) {
return false;
}
}

return true;
}

/**
* @param callable(Expr): Type $getTypeCallback
*/
Expand Down
6 changes: 2 additions & 4 deletions src/Type/Accessory/AccessoryDecimalIntegerStringType.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,8 @@ public function tryRemove(Type $typeToRemove): ?Type
public function toNumber(): Type
{
if ($this->inverse) {
return new UnionType([
$this->toInteger(),
$this->toFloat(),
]);
// a non-decimal-int-string can be an arbitrary non-numeric string like "foo"
return new ErrorType();
}

return $this->toInteger();
Expand Down
69 changes: 69 additions & 0 deletions src/Type/DecimalIntegerStringHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type;

use Nette\Utils\Strings;
use function in_array;

/**
* Decides whether a string that is built out of several parts can still be a decimal-int-string
* once one of those parts is a known constant.
*
* A decimal-int-string is the canonical string form of an integer: digits with an optional
* leading `-` and without redundant leading zeros. So `"0"`, `"1"`, `"1234"` and `"-1"` are
* decimal-int-strings while `"+1"`, `"00"`, `"-0"` and `"foo"` are not.
*
* These checks are only sound for constant parts. Knowing that an operand is a
* non-decimal-int-string proves nothing about the result, because `'-'` is a
* non-decimal-int-string while `'-' . 1` is the decimal-int-string `'-1'`.
*/
final class DecimalIntegerStringHelper
{

/**
* Whether $value can be the beginning of a decimal-int-string.
*
* @param bool $restCanBeEmpty whether the part following $value can be an empty string
*/
public static function canStart(string $value, bool $restCanBeEmpty): bool
{
if (in_array($value, ['', '-'], true)) {
return true;
}

if ($value === '0') {
return $restCanBeEmpty;
}

return Strings::match($value, '#^-?[1-9][0-9]*$#') !== null;
}

/**
* Whether $value can be the end of a decimal-int-string.
*
* @param bool $restCanBeEmpty whether the part preceding $value can be an empty string
*/
public static function canEnd(string $value, bool $restCanBeEmpty): bool
{
if (Strings::match($value, '#^[0-9]*$#') !== null) {
return true;
}

return $restCanBeEmpty && Strings::match($value, '#^-[1-9][0-9]*$#') !== null;
}

/**
* Whether $value can appear inside a decimal-int-string with an unknown part after it.
*
* @param bool $restBeforeCanBeEmpty whether the part preceding $value can be an empty string
*/
public static function canBeInside(string $value, bool $restBeforeCanBeEmpty): bool
{
if (Strings::match($value, '#^[0-9]*$#') !== null) {
return true;
}

return $restBeforeCanBeEmpty && self::canStart($value, true);
}

}
46 changes: 44 additions & 2 deletions src/Type/Php/ImplodeFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\InitializerExprTypeResolver;
use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType;
use PHPStan\Type\Accessory\AccessoryLiteralStringType;
use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
use PHPStan\Type\Accessory\AccessoryUppercaseStringType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DecimalIntegerStringHelper;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
Expand Down Expand Up @@ -103,14 +105,54 @@ private function implode(Type $arrayType, Type $separatorType): Type
$accessoryTypes[] = new AccessoryUppercaseStringType();
}

if ($this->isNonDecimalIntegerString($arrayType, $valueTypeAsString, $separatorType)) {
$accessoryTypes[] = new AccessoryDecimalIntegerStringType(inverse: true);
}

if (count($accessoryTypes) > 0) {
$accessoryTypes[] = new StringType();
return new IntersectionType($accessoryTypes);
return TypeCombinator::intersect(...$accessoryTypes);
}

return new StringType();
}

/**
* The separator ends up surrounded by values, and every value of a non-empty array ends up
* somewhere in the result. So a constant separator or constant value that cannot occur
* inside a decimal-int-string proves the whole result is not one.
*/
private function isNonDecimalIntegerString(Type $arrayType, Type $valueTypeAsString, Type $separatorType): bool
{
if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($arrayType->getArraySize())->yes()) {
if ($this->allConstantStringsCannotBeInside($separatorType, !$valueTypeAsString->isNonEmptyString()->yes())) {
return true;
}
}

if (!$arrayType->isIterableAtLeastOnce()->yes()) {
return false;
}

return $this->allConstantStringsCannotBeInside($valueTypeAsString, true);
}

private function allConstantStringsCannotBeInside(Type $type, bool $restBeforeCanBeEmpty): bool
{
$constantStrings = $type->getConstantStrings();
if (count($constantStrings) === 0) {
return false;
}

foreach ($constantStrings as $constantString) {
if (DecimalIntegerStringHelper::canBeInside($constantString->getValue(), $restBeforeCanBeEmpty)) {
return false;
}
}

return true;
}

private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType, bool $isNonEmpty): ?Type
{
// Unsealed extras can append further segments the constant fold
Expand Down
87 changes: 72 additions & 15 deletions src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

namespace PHPStan\Type\Php;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType;
use PHPStan\Type\Accessory\AccessoryNumericStringType;
use PHPStan\Type\DecimalIntegerStringHelper;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;
use function in_array;

Expand All @@ -25,28 +29,81 @@

public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
{
$stringType = new StringType();
if (!isset($functionCall->getArgs()[3])) {
return $stringType;
$args = $functionCall->getArgs();

$accessoryTypes = [];
if ($this->isNumericString($args, $scope)) {
$accessoryTypes[] = new AccessoryNumericStringType();
}
if ($this->isNonDecimalIntegerString($args, $scope)) {
$accessoryTypes[] = new AccessoryDecimalIntegerStringType(inverse: true);
}

if (count($accessoryTypes) === 0) {
return new StringType();
}

$thousandsType = $scope->getType($functionCall->getArgs()[3]->value);
$decimalType = $scope->getType($functionCall->getArgs()[2]->value);
$accessoryTypes[] = new StringType();

return TypeCombinator::intersect(...$accessoryTypes);
}

/**
* @param array<Arg> $args
*/
private function isNumericString(array $args, Scope $scope): bool
{
if (!isset($args[3])) {
return false;
}

$constantThousandsTypes = $thousandsType->getConstantStrings();
$constantThousandsTypes = $scope->getType($args[3]->value)->getConstantStrings();
if (count($constantThousandsTypes) !== 1 || $constantThousandsTypes[0]->getValue() !== '') {
return $stringType;
return false;
}

$constantScalarValues = $scope->getType($args[2]->value)->getConstantScalarValues();

return count($constantScalarValues) === 1 && in_array($constantScalarValues[0], [null, '.', ''], true);
}

/**
* With at least one decimal the decimal separator always ends up between digits,
* so a separator that cannot occur in a decimal-int-string rules the whole result out.
*
* @param array<Arg> $args
*/
private function isNonDecimalIntegerString(array $args, Scope $scope): bool
{
if (!isset($args[1])) {
return false;
}

if (!IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($scope->getType($args[1]->value))->yes()) {
return false;
}

if (!isset($args[2])) {
return true;
}

$decimalSeparatorType = $scope->getType($args[2]->value);
if ($decimalSeparatorType->isNull()->yes()) {

Check warning on line 91 in src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $decimalSeparatorType = $scope->getType($args[2]->value); - if ($decimalSeparatorType->isNull()->yes()) { + if (!$decimalSeparatorType->isNull()->no()) { return true; }

Check warning on line 91 in src/Type/Php/NumberFormatFunctionDynamicReturnTypeExtension.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } $decimalSeparatorType = $scope->getType($args[2]->value); - if ($decimalSeparatorType->isNull()->yes()) { + if (!$decimalSeparatorType->isNull()->no()) { return true; }
return true;
}

$constantSeparators = $decimalSeparatorType->getConstantStrings();
if (count($constantSeparators) === 0) {
return false;
}

$constantScalarValues = $decimalType->getConstantScalarValues();
if (count($constantScalarValues) !== 1 || !in_array($constantScalarValues[0], [null, '.', ''], true)) {
return $stringType;
foreach ($constantSeparators as $constantSeparator) {
if (DecimalIntegerStringHelper::canBeInside($constantSeparator->getValue(), false)) {
return false;
}
}

return new IntersectionType([
$stringType,
new AccessoryNumericStringType(),
]);
return true;
}

}
Loading
Loading