Skip to content
129 changes: 117 additions & 12 deletions src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,13 @@ public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCall
$result = $this->getFiniteOrConstantScalarTypes($leftType, $rightType, static fn ($a, $b) => $a & $b);
if ($result instanceof Type) {
return $result;
} elseif ($result === self::IS_SCALAR_TYPE) {
}

// computed before optimizeScalarType() below widens integer ranges to int
$leftNumberType = $leftType->toNumber();
$rightNumberType = $rightType->toNumber();

if ($result === self::IS_SCALAR_TYPE) {
$leftType = $this->optimizeScalarType($leftType);
$rightType = $this->optimizeScalarType($rightType);
}
Expand All @@ -1084,18 +1090,13 @@ public function getBitwiseAndType(Expr $left, Expr $right, callable $getTypeCall
return new ErrorType();
}

$leftNumberType = $leftType->toNumber();
$rightNumberType = $rightType->toNumber();

if ($leftNumberType instanceof ErrorType || $rightNumberType instanceof ErrorType) {
if ($leftType->toNumber() instanceof ErrorType || $rightType->toNumber() instanceof ErrorType) {
return new ErrorType();
}

if ($rightNumberType instanceof ConstantIntegerType && $rightNumberType->getValue() >= 0) {
return IntegerRangeType::fromInterval(0, $rightNumberType->getValue());
}
if ($leftNumberType instanceof ConstantIntegerType && $leftNumberType->getValue() >= 0) {
return IntegerRangeType::fromInterval(0, $leftNumberType->getValue());
$bitwiseAndRange = $this->computeBitwiseAndRange($leftNumberType, $rightNumberType);
if ($bitwiseAndRange !== null) {
return $bitwiseAndRange;
}

return new IntegerType();
Comment thread
staabm marked this conversation as resolved.
Expand All @@ -1122,7 +1123,13 @@ public function getBitwiseOrType(Expr $left, Expr $right, callable $getTypeCallb
$result = $this->getFiniteOrConstantScalarTypes($leftType, $rightType, static fn ($a, $b) => $a | $b);
if ($result instanceof Type) {
return $result;
} elseif ($result === self::IS_SCALAR_TYPE) {
}

// computed before optimizeScalarType() below widens integer ranges to int
$leftNumberType = $leftType->toNumber();
$rightNumberType = $rightType->toNumber();

if ($result === self::IS_SCALAR_TYPE) {
$leftType = $this->optimizeScalarType($leftType);
$rightType = $this->optimizeScalarType($rightType);
}
Expand All @@ -1147,6 +1154,11 @@ public function getBitwiseOrType(Expr $left, Expr $right, callable $getTypeCallb
return new ErrorType();
}

$bitwiseOrRange = $this->computeBitwiseOrXorRange($leftNumberType, $rightNumberType);
if ($bitwiseOrRange !== null) {
return $bitwiseOrRange;
}

return new IntegerType();
}

Expand All @@ -1171,7 +1183,13 @@ public function getBitwiseXorType(Expr $left, Expr $right, callable $getTypeCall
$result = $this->getFiniteOrConstantScalarTypes($leftType, $rightType, static fn ($a, $b) => $a ^ $b);
if ($result instanceof Type) {
return $result;
} elseif ($result === self::IS_SCALAR_TYPE) {
}

// computed before optimizeScalarType() below widens integer ranges to int
$leftNumberType = $leftType->toNumber();
$rightNumberType = $rightType->toNumber();

if ($result === self::IS_SCALAR_TYPE) {
$leftType = $this->optimizeScalarType($leftType);
$rightType = $this->optimizeScalarType($rightType);
}
Expand All @@ -1196,6 +1214,11 @@ public function getBitwiseXorType(Expr $left, Expr $right, callable $getTypeCall
return new ErrorType();
}

$bitwiseXorRange = $this->computeBitwiseOrXorRange($leftNumberType, $rightNumberType);
if ($bitwiseXorRange !== null) {
return $bitwiseXorRange;
}

return new IntegerType();
}

Expand Down Expand Up @@ -1985,6 +2008,88 @@ private function optimizeScalarType(Type $type): Type
return new UnionType($types);
}

/**
* @return array{int, int|null}|null [min, max] of a type known to be a non-negative integer,
* with a null max when it has no finite upper bound. Null when the type is not known
* to be a non-negative integer.
*/
private function getNonNegativeIntegerBounds(Type $type): ?array
{
if ($type instanceof IntegerRangeType) {
$min = $type->getMin();
if ($min !== null && $min >= 0) {
return [$min, $type->getMax()];
}
return null;
}
if ($type instanceof ConstantIntegerType && $type->getValue() >= 0) {
return [$type->getValue(), $type->getValue()];
}
return null;
}

/**
* Expects the operands already converted with toNumber().
*
* A single non-negative operand is enough to bound the result: the result's bits
* are a subset of that operand's bits, so it stays within int<0, thatMax>.
* With no finite max on any non-negative side the result is still int<0, max>.
*/
private function computeBitwiseAndRange(Type $leftNumberType, Type $rightNumberType): ?Type
{
$leftBounds = $this->getNonNegativeIntegerBounds($leftNumberType);
$rightBounds = $this->getNonNegativeIntegerBounds($rightNumberType);
if ($leftBounds === null && $rightBounds === null) {
return null;
}

$maxValues = [];
if ($leftBounds !== null && $leftBounds[1] !== null) {
$maxValues[] = $leftBounds[1];
}
if ($rightBounds !== null && $rightBounds[1] !== null) {
$maxValues[] = $rightBounds[1];
}

return IntegerRangeType::fromInterval(0, $maxValues === [] ? null : min($maxValues));
}

/**
* Expects the operands already converted with toNumber().
*
* Both operands have to be non-negative: they keep the sign bit of the result clear.
* Without a finite max on either side the result is still int<0, max>.
*/
private function computeBitwiseOrXorRange(Type $leftNumberType, Type $rightNumberType): ?Type
{
$leftBounds = $this->getNonNegativeIntegerBounds($leftNumberType);
$rightBounds = $this->getNonNegativeIntegerBounds($rightNumberType);
if ($leftBounds === null || $rightBounds === null) {
return null;
}
if ($leftBounds[1] === null || $rightBounds[1] === null) {
return IntegerRangeType::fromInterval(0, null);
}

return IntegerRangeType::fromInterval(0, self::allBitsMask(max($leftBounds[1], $rightBounds[1])));
}

/**
* Propagates the highest set bit of a non-negative value into all lower bits:
* 200 becomes 255, 10 becomes 15.
*/
private static function allBitsMask(int $value): int
{
$value |= $value >> 1;
$value |= $value >> 2;
$value |= $value >> 4;
$value |= $value >> 8;
$value |= $value >> 16;
$value |= $value >> 32;

return $value;
}

/**
* @return TypeResult<BooleanType>
*/
Expand Down
9 changes: 9 additions & 0 deletions src/Type/IntegerRangeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,15 @@ public function toAbsoluteNumber(): Type
return self::fromInterval(-$this->max, $inversedMin);
}

public function toBitwiseNotType(): Type
{
// ~int<a, b> = int<~b, ~a> (bitwise NOT reverses the order)
return self::fromInterval(
$this->max !== null ? ~$this->max : null,
$this->min !== null ? ~$this->min : null,
);
}

public function toString(): Type
{
$finiteTypes = $this->getFiniteTypes();
Expand Down
102 changes: 102 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14654.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php declare(strict_types = 1);

namespace Bug14654;

use function PHPStan\Testing\assertType;

function foo(): void {
$a = \ord('a');
$b = \ord('b');

assertType('int<0, 255>', $a);
assertType('int<0, 255>', $b);
assertType('int<0, 255>', $a ^ $b);
assertType('int<0, 255>', $a | $b);
assertType('int<0, 255>', $a & $b);
}

/**
* @param int<0, 255> $c
*/
function withConstantOperand(int $c): void {
assertType('int<0, 255>', $c ^ 42);
assertType('int<0, 255>', 42 ^ $c);
assertType('int<0, 255>', $c | 42);
assertType('int<0, 42>', $c & 42);
}

/**
* @param int<0, 255> $c
*/
function withOperandCoercedToInteger(int $integer, int $c): void {
assertType('int<0, 3>', $integer & '3');
assertType('int<0, 3>', '3' & $integer);
assertType('int<0, 1>', $integer & true);
assertType('int<0, 255>', $c ^ '3');
assertType('int<0, 255>', $c | '3');
assertType('int<0, 3>', $c & '3');
}

/**
* @param int<0, 20> $x
* @param int<0, 20> $y
*/
function smallRanges(int $x, int $y): void {
assertType('int<0, 31>', $x ^ $y);
assertType('int<0, 31>', $x | $y);
assertType('int<0, 20>', $x & $y);
}

/**
* @param int<0, 255> $a
* @param int<0, 20> $x
*/
function differentRangeSizes(int $a, int $x): void {
assertType('int<0, 20>', $a & $x);
assertType('int<0, 20>', $x & $a);
}

/**
* @param int<0, max> $unbounded
* @param int<0, 255> $a
*/
function unboundedRanges(int $unbounded, int $a): void {
assertType('int<0, max>', $unbounded ^ $a);
assertType('int<0, max>', $unbounded | $a);
assertType('int<0, 255>', $unbounded & $a);
}

function nonNegativeUnboundedFunctionCalls(string $a, string $b): void {
assertType('int<0, max>', strlen($a) | strlen($b));
assertType('int<0, max>', strlen($a) ^ strlen($b));
assertType('int<0, max>', strlen($a) & strlen($b));
assertType('int<0, max>', strlen($a) | 8);
assertType('int<0, 8>', strlen($a) & 8);
}

/**
* @param int<-10, 10> $signed
* @param int<0, 20> $x
* @param int<0, max> $unbounded
*/
function negativeRanges(int $signed, int $x, int $unbounded): void {
assertType('int', $signed ^ $x);
assertType('int', $signed | $x);
assertType('int<0, 20>', $signed & $x);
assertType('int', $signed ^ $unbounded);
assertType('int', $signed | $unbounded);
assertType('int<0, max>', $signed & $unbounded);
}

/**
* @param int<0, 255> $a
* @param int<0, 20> $x
* @param int<min, 10> $minBounded
* @param int<-5, max> $maxBounded
*/
function bitwiseNot(int $a, int $x, int $minBounded, int $maxBounded): void {
assertType('int<-256, -1>', ~$a);
assertType('int<-21, -1>', ~$x);
assertType('int<-11, max>', ~$minBounded);
assertType('int<min, 4>', ~$maxBounded);
}
Loading