diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 0b2c527049..10a913ac5d 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -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); } @@ -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(); @@ -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); } @@ -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(); } @@ -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); } @@ -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(); } @@ -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 */ diff --git a/src/Type/IntegerRangeType.php b/src/Type/IntegerRangeType.php index 605507008b..9aa0a55283 100644 --- a/src/Type/IntegerRangeType.php +++ b/src/Type/IntegerRangeType.php @@ -511,6 +511,15 @@ public function toAbsoluteNumber(): Type return self::fromInterval(-$this->max, $inversedMin); } + public function toBitwiseNotType(): Type + { + // ~int = 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(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14654.php b/tests/PHPStan/Analyser/nsrt/bug-14654.php new file mode 100644 index 0000000000..3e87f46fb9 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14654.php @@ -0,0 +1,102 @@ +', $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 $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', ~$maxBounded); +}