From 6d7e1f0813de61b8fbfa2e785cd889a1db72edce Mon Sep 17 00:00:00 2001 From: Giandonn Date: Fri, 2 Oct 2026 21:28:23 +0000 Subject: [PATCH] fix(compiler): convert float operands of %, <<, >>, &, | and ^ to int PHP converts a float operand of these operators to int and always yields an int. TypePHP inferred float for them and emitted the raw C++ operator, which does not exist for double, so ordinary PHP code failed in the C++ compiler: function f(float $a, float $b): int { return $a & $b; } // error: invalid operands of types php::Float and php::Float // to binary operator& The same happened for |, ^, << and >>, for an int and a float operand, and for the compound forms on an int local ($x &= 3.0, $x %= 2.5). The type of `$x = $float % $int` was also inferred as float (var_dump printed float(1) where PHP prints int(1)). - Type inference: with a float operand, %, <<, >>, &, | and ^ produce int. - Codegen: a bitwise or shift operator with a float operand goes through the php::Var operator, so Zend applies its own float-to-int conversion (the deprecation of a lossy conversion, 0 for NAN/INF/out of range, and the ArithmeticError of a negative shift). This happens before the Int -> Float promotion, which would round a large int operand (PHP_INT_MAX ^ 1.0). - An int local's compound %=, &=, |=, ^=, <<= or >>= with a float operand takes the same route. On a native float local the result would have to become an int, which a native local cannot do; that is now a compile-time error instead of invalid C++. --- .../code/float_local_int_only_assign_op.php | 7 +++ phpunit/src/FloatLocalIntOnlyAssignOpTest.php | 15 +++++ src/CompilerBase.php | 11 +++- src/Parser/AssignOpTrait.php | 20 +++++++ src/Parser/BinaryOpTrait.php | 13 +++++ .../operator/float-bitwise-operands.phpt | 58 +++++++++++++++++++ 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 phpunit/code/float_local_int_only_assign_op.php create mode 100644 phpunit/src/FloatLocalIntOnlyAssignOpTest.php create mode 100755 tests/compiler/operator/float-bitwise-operands.phpt diff --git a/phpunit/code/float_local_int_only_assign_op.php b/phpunit/code/float_local_int_only_assign_op.php new file mode 100644 index 00000000..a4eaca23 --- /dev/null +++ b/phpunit/code/float_local_int_only_assign_op.php @@ -0,0 +1,7 @@ +>= into an int and the + * result is an int, so a native float local would have to change type. That is + * rejected at compile time instead of emitting a C++ operator that does not + * exist for double. + */ +class FloatLocalIntOnlyAssignOpTest extends BaseTest +{ + public function testIntOnlyCompoundAssignmentOnNativeFloatLocalIsRejected(): void + { + $this->exec("Cannot apply %= to a native float variable: PHP converts the result to int", "float_local_int_only_assign_op.php"); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index db84d361..3503faa7 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3540,7 +3540,16 @@ protected function detectTypeOfExpr($expr): string return Type::VAR; } if ($leftType === Type::FLOAT || $rightType === Type::FLOAT) { - return Type::FLOAT; + // PHP converts float operands of %, <<, >>, &, | and ^ to int, + // so these operators always produce an int. + return in_array($exprType, [ + 'Expr_BinaryOp_Mod', + 'Expr_BinaryOp_ShiftLeft', + 'Expr_BinaryOp_ShiftRight', + 'Expr_BinaryOp_BitwiseAnd', + 'Expr_BinaryOp_BitwiseOr', + 'Expr_BinaryOp_BitwiseXor', + ], true) ? Type::INT : Type::FLOAT; } if ($this->varIntTypes && $leftType === Type::INT && $rightType === Type::INT) { $op = match ($exprType) { diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 5e46785f..2a9a38bd 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -1026,6 +1026,26 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string return $this->parseBigAssignOp($node, $var, $type, $expr, $rightType, $op); } + // C++ has no %, bitwise or shift compound operator for double. PHP + // converts float operands of these to int and always yields an int, + // so an int local takes the Variant operator (Zend's conversion), + // and a native float local would have to change type, which native + // locals cannot do. + if (in_array($op, ['%=', '&=', '|=', '^=', '<<=', '>>='], true) + && ($type === Type::FLOAT || $rightType === Type::FLOAT) + ) { + if ($type === Type::FLOAT) { + $this->fatalError( + $node, + "Cannot apply {$op} to a native float variable: PHP converts the result to int", + ); + } + if ($type === Type::INT) { + return $var . ' = php::toInt(((php::Var(' . $var . ')) ' . $this->removeAssignOp($op) + . ' (php::Var(' . $expr . '))))'; + } + } + // A dynamic local must retain the RHS runtime type. Variant's // compound operators already implement PHP coercion and checked // integer overflow; eagerly converting an int-looking expression diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index fed76ccb..f12abf50 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -120,6 +120,19 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string $this->fatalError($left, "Operator '{$op}' is not supported for Big* numeric types"); } + // C++ has no bitwise or shift operators for double. PHP converts each + // float operand to int (deprecating a lossy conversion) and yields an + // int, so let the Variant operator apply Zend's conversion. This must + // happen before the Int -> Float promotion below, which would round a + // large int operand. + if (in_array($op, ['&', '|', '^', '<<', '>>'], true) + && ($leftType === Type::FLOAT || $rightType === Type::FLOAT) + ) { + return $this->convertIntExpr( + '((php::Var(' . $leftExpr . ')) ' . $op . ' (php::Var(' . $rightExpr . ')))' + ); + } + // Only promote between native types (Int ↔ Float). When one side is // php::Var, let the Variant operator handle type coercion so that // run-time PHP type-juggling rules are followed correctly. diff --git a/tests/compiler/operator/float-bitwise-operands.phpt b/tests/compiler/operator/float-bitwise-operands.phpt new file mode 100755 index 00000000..714f3712 --- /dev/null +++ b/tests/compiler/operator/float-bitwise-operands.phpt @@ -0,0 +1,58 @@ +--TEST-- +%, <<, >>, &, | and ^ convert float operands to int (as PHP does) and produce an int +--FILE-- +> $b; } +function md(float $a, int $b): mixed { $x = $a % $b; return $x; } +function andAssign(float $a): mixed { $x = 7; $x &= $a; return $x; } +function modAssign(float $a): mixed { $x = 7; $x %= $a; return $x; } + +function t(callable $f): void +{ + try { + var_dump($f()); + } catch (\Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; + } +} + +function main(): void +{ + ini_set('display_errors', '0'); // lossy float-to-int conversions are deprecated + t(fn() => band(6.0, 3.0)); + t(fn() => band(-1.0, 255.0)); + t(fn() => band(1.5, 1.0)); + t(fn() => band(NAN, 1.0)); + t(fn() => bor(2.0, PHP_INT_MAX)); + t(fn() => bxor(PHP_INT_MAX, 1.0)); + t(fn() => shl(1.0, 3.0)); + t(fn() => shl(1.0, 64.0)); + t(fn() => shl(1.0, -1.0)); + t(fn() => shr(-8.0, 1)); + t(fn() => md(7.5, 2)); + t(fn() => md(7.5, 0)); + t(fn() => andAssign(3.0)); + t(fn() => modAssign(2.5)); + t(fn() => 6.0 & 3.0); +} +?> +--EXPECT-- +int(2) +int(255) +int(1) +int(0) +int(9223372036854775807) +int(9223372036854775806) +int(8) +int(0) +ArithmeticError: Bit shift by negative number +int(-4) +int(1) +DivisionByZeroError: Modulo by zero +int(3) +int(1) +int(2)