diff --git a/phpunit/code/typed-scalar-arithmetic-codegen.php b/phpunit/code/typed-scalar-arithmetic-codegen.php new file mode 100644 index 00000000..87f0a9da --- /dev/null +++ b/phpunit/code/typed-scalar-arithmetic-codegen.php @@ -0,0 +1,26 @@ +> $b; +} diff --git a/phpunit/src/OperatorTest.php b/phpunit/src/OperatorTest.php index 0e569f66..82f59dfe 100644 --- a/phpunit/src/OperatorTest.php +++ b/phpunit/src/OperatorTest.php @@ -56,34 +56,61 @@ public function testDynamicBoolCallInLogicalExpressionIsConvertedToNativeBool(): $this->assertStringContainsString('php::toBool(php::call(', $cpp); } - public function testLiteralIntDivideByZeroDoesNotCompile(): void + /** + * A literal zero divisor is valid PHP: it raises a catchable + * DivisionByZeroError only when the statement executes, so it must + * compile (with a warning) and defer to the runtime error, exactly like + * the already-accepted `1 % (1 - 1)` and `10 / ZERO` spellings. + */ + public function testLiteralIntDivideByZeroCompilesToRuntimeError(): void { - $this->exec('Cannot divide or modulo by zero', 'divide-by-zero-int.php'); + $cpp = $this->compileToCpp('divide-by-zero-int.php'); + $this->assertMatchesRegularExpression('/\(\(php::Var\(10L{1,2}\)\) \/ \(php::Var\(0L{1,2}\)\)\)/', $cpp); } - public function testLiteralFloatDivideByZeroDoesNotCompile(): void + public function testLiteralFloatDivideByZeroCompilesToRuntimeError(): void { - $this->exec('Cannot divide or modulo by zero', 'divide-by-zero-float.php'); + $cpp = $this->compileToCpp('divide-by-zero-float.php'); + $this->assertStringContainsString('((php::Var(1.0)) / (php::Var(0.0)))', $cpp); } - public function testLiteralStringDivideByZeroDoesNotCompile(): void + public function testLiteralStringDivideByZeroCompilesToRuntimeError(): void { - $this->exec('Cannot divide or modulo by zero', 'divide-by-zero-string.php'); + // The string operand keeps the Variant operator, which raises the + // catchable DivisionByZeroError at runtime. + $this->compile('divide-by-zero-string.php'); } - public function testLiteralModuloByZeroDoesNotCompile(): void + public function testLiteralModuloByZeroCompilesToRuntimeError(): void { - $this->exec('Cannot divide or modulo by zero', 'modulo-by-zero-int.php'); + $cpp = $this->compileToCpp('modulo-by-zero-int.php'); + $this->assertMatchesRegularExpression('/\(\(php::Var\(10L{1,2}\)\) % \(php::Var\(0L{1,2}\)\)\)/', $cpp); } - public function testLiteralDivideAssignByZeroDoesNotCompile(): void + public function testLiteralDivideAssignByZeroCompilesToRuntimeError(): void { - $this->exec('Cannot divide or modulo by zero', 'assign-divide-by-zero.php'); + $cpp = $this->compileToCpp('assign-divide-by-zero.php'); + $this->assertStringContainsString('value /= ', $cpp); } - public function testLiteralModuloAssignByZeroDoesNotCompile(): void + public function testLiteralModuloAssignByZeroCompilesToRuntimeError(): void { - $this->exec('Cannot divide or modulo by zero', 'assign-modulo-by-zero.php'); + $cpp = $this->compileToCpp('assign-modulo-by-zero.php'); + $this->assertStringContainsString('value %= ', $cpp); + } + + private function compileToCpp(string $file): string + { + global $translator; + $compiler = \TypePhp\CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $testFile = __DIR__ . '/../code/' . $file; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $cppFile = $compiler->convertFile($testFile); + $cpp = file_get_contents($cppFile); + $this->assertIsString($cpp); + return $cpp; } public function testFloatLiteralSpecialValuesAndWholeNumbers(): void diff --git a/phpunit/src/TypedScalarArithmeticCodegenTest.php b/phpunit/src/TypedScalarArithmeticCodegenTest.php new file mode 100644 index 00000000..4680e0d7 --- /dev/null +++ b/phpunit/src/TypedScalarArithmeticCodegenTest.php @@ -0,0 +1,55 @@ +compileFixture(); + + self::assertStringContainsString('((php::Var(a)) / (php::Var(b)))', $code); + self::assertStringNotContainsString('((a) / (b))', $code); + } + + public function testTypedIntModuloRoutesThroughPhpMod(): void + { + $code = $this->compileFixture(); + + self::assertStringContainsString('php::fn::mod(a, b)', $code); + self::assertStringNotContainsString('((a) % (b))', $code); + } + + public function testTypedIntShiftsRouteThroughVariant(): void + { + $code = $this->compileFixture(); + + self::assertStringContainsString('((php::Var(a)) << (php::Var(b)))', $code); + self::assertStringContainsString('((php::Var(a)) >> (php::Var(b)))', $code); + self::assertStringNotContainsString('((a) << (b))', $code); + self::assertStringNotContainsString('((a) >> (b))', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/typed-scalar-arithmetic-codegen.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 16d1652d..28455ef7 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -864,6 +864,24 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string $propertyWriteTarget = $this->preparePropertyWriteTarget($node->var); $this->guardLiteralDivisionByZero($node->expr, $op); + // A compound division/modulo on a NATIVE scalar slot with a proven + // zero divisor cannot fall through to the raw C++ operator (SIGFPE + // for ints, INF for floats). A zero divisor always throws the + // catchable DivisionByZeroError before any assignment happens, so + // lower the whole expression to the PHP-semantics binary operation + // and leave the target untouched. + if (($op === '/=' || $op === '%=') + && !$this->nativeTypes + && $this->isZeroLiteral($node->expr) + && $this->isVarExpr($node->var) + && $this->hasVar((string) $this->parseIdentifier($node->var)) + && in_array($this->detectVarType($node->var), [Type::INT, Type::FLOAT], true) + ) { + $binOp = $op === '/=' ? '/' : '%'; + return '((php::Var(' . $this->parseExprAsValue($node->var) . ')) ' + . $binOp . ' (php::Var(' . $this->parseExprAsValue($node->expr) . ')))'; + } + if ($node->var instanceof Expr\PropertyFetch && $this->isNativeObjectPropertyHook($node->var)) { $this->fatalError( $node->var, diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index e2c35d76..94f743a3 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -139,8 +139,20 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string return $constantDivisionByZero; } - if ($op === '%' and !($leftType === Type::INT and $rightType === Type::INT)) { - return 'php::fn::mod(' . $leftExpr . ', ' . $rightExpr . ')'; + if ($op === '%') { + if (!($leftType === Type::INT and $rightType === Type::INT)) { + return 'php::fn::mod(' . $leftExpr . ', ' . $rightExpr . ')'; + } + // PHP int modulo raises a catchable DivisionByZeroError for a + // zero divisor and defines PHP_INT_MIN % -1 as 0; the raw C++ '%' + // is undefined behavior for both. Route dynamic int modulo through + // the PHP mod function unless the user explicitly selected + // `use native_types`. Constant operands are folded below. + if (!$this->nativeTypes + && $this->evaluateConstantIntArithmetic($left, $right, '%') === null + ) { + return 'php::fn::mod(' . $leftExpr . ', ' . $rightExpr . ')'; + } } if ($op === '<<' || $op === '>>') { @@ -148,6 +160,30 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string if ($foldedShift !== null) { return $foldedShift; } + + // PHP shifts by >= the word size yield 0 (or -1 for a negative + // right-shifted value) and negative shift counts raise a catchable + // ArithmeticError, while the raw C++ shift is undefined behavior + // for both; a raw left shift into the sign bit is also undefined. + // Route dynamic int shifts through the encapsulated Variant + // operators unless the user explicitly selected `use native_types`. + // Constant shifts that C++ defines identically to PHP stay raw. + if (!$this->nativeTypes + && $leftType === Type::INT + && $rightType === Type::INT + ) { + $leftValue = $this->constantIntValue($left); + $shiftValue = $this->constantIntValue($right); + $safeConstantShift = $leftValue !== null + && $shiftValue !== null + && $leftValue >= 0 + && $shiftValue >= 0 + && $shiftValue < PHP_INT_SIZE * 8 + && ($op === '>>' || !$this->leftShiftTouchesSignBit($leftValue, $shiftValue)); + if (!$safeConstantShift) { + return '((php::Var(' . $leftExpr . ')) ' . $op . ' (php::Var(' . $rightExpr . ')))'; + } + } } $folded = $this->tryFoldConstantIntArithmetic($left, $right, $op); @@ -170,6 +206,24 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string return '((php::Var(' . $leftExpr . ')) ' . $op . ' (php::Var(' . $rightExpr . ')))'; } + // PHP division on native scalar operands cannot be emitted as a raw + // C++ '/': zend_long division truncates (7 / 2 is 3.5 in PHP, 3 in + // C++), division by zero must raise the catchable DivisionByZeroError + // (raw integer division is UB, raw double division yields INF/NAN), + // and PHP_INT_MIN / -1 promotes to float. Route dynamic division + // through the encapsulated Variant operator unless the user explicitly + // selected `use native_types`. Fully constant operands are folded + // above or are exact when emitted directly. + if (!$this->nativeTypes + && $op === '/' + && in_array($leftType, [Type::INT, Type::FLOAT], true) + && in_array($rightType, [Type::INT, Type::FLOAT], true) + && ($this->constantNumericValue($left, false) === null + || $this->constantNumericValue($right, false) === null) + ) { + return '((php::Var(' . $leftExpr . ')) / (php::Var(' . $rightExpr . ')))'; + } + return '((' . $leftExpr . ') ' . $op . ' (' . $rightExpr . '))'; } @@ -503,21 +557,25 @@ protected function handleNestedConstantDivisionByZero( string $leftExpr, string $rightExpr ): ?string { - if (($op !== '/' && $op !== '%') || $this->isZeroLiteral($right)) { + if ($op !== '/' && $op !== '%') { return null; } - $rightValue = $this->constantNumericValue($right, $this->nativeTypes); - if ($rightValue === null || $rightValue != 0) { - return null; + if (!$this->isZeroLiteral($right)) { + $rightValue = $this->constantNumericValue($right, $this->nativeTypes); + if ($rightValue === null || $rightValue != 0) { + return null; + } } if ($this->nativeTypes) { $this->fatalError($right, 'Constant division or modulo by zero has undefined behavior in C++ native mode'); } - // Preserve PHP's catchable DivisionByZeroError for a nested constant - // zero. Literal zero keeps the compiler's established diagnostic. + // Preserve PHP's catchable DivisionByZeroError for a constant zero + // divisor, whether spelled as a literal or a folded expression. Even + // statically detectable, the operation only throws when the statement + // actually executes, so it must not reject compilation. return '((php::Var(' . $leftExpr . ')) ' . $op . ' (php::Var(' . $rightExpr . ')))'; } @@ -1215,7 +1273,13 @@ protected function parseBinaryOpDiv(Expr\BinaryOp\Div $expr): string protected function guardLiteralDivisionByZero(NodeAbstract $right, string $op): void { if (($op === '/' or $op === '%' or $op === '/=' or $op === '%=') and $this->isZeroLiteral($right)) { - $this->fatalError($right, 'Cannot divide or modulo by zero'); + if ($this->nativeTypes) { + $this->fatalError($right, 'Cannot divide or modulo by zero'); + } + // PHP raises a catchable DivisionByZeroError at runtime, and only + // when the statement actually executes; dead or guarded code with + // a literal zero divisor is valid PHP. Warn instead of rejecting. + $this->warning($right, 'Division or modulo by zero throws DivisionByZeroError at runtime'); } } diff --git a/tests/compiler/operator/literal-division-by-zero-runtime.phpt b/tests/compiler/operator/literal-division-by-zero-runtime.phpt new file mode 100644 index 00000000..1f07b29e --- /dev/null +++ b/tests/compiler/operator/literal-division-by-zero-runtime.phpt @@ -0,0 +1,54 @@ +--TEST-- +Literal zero divisors compile and raise catchable DivisionByZeroError at runtime +--FILE-- +getMessage() . "\n"; + } + + try { + $f = 1.0 / 0.0; + var_dump($f); + } catch (DivisionByZeroError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + + $v = 10; + try { + $v /= 0; + } catch (DivisionByZeroError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + var_dump($v); + + $w = 10; + try { + $w %= 0; + } catch (DivisionByZeroError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + var_dump($w); +} +?> +--EXPECT-- +dead code ok +caught: Division by zero +caught: Division by zero +caught: Division by zero +int(10) +caught: Modulo by zero +int(10) diff --git a/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt b/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt new file mode 100644 index 00000000..45fd856b --- /dev/null +++ b/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt @@ -0,0 +1,54 @@ +--TEST-- +Literal zero divisors on typed native slots raise catchable DivisionByZeroError +--FILE-- +getMessage(); + } + return $value; +} + +function modInt(int $value): mixed +{ + try { + $value %= 0; + } catch (DivisionByZeroError $e) { + return $e->getMessage(); + } + return $value; +} + +function divFloat(float $value): mixed +{ + try { + $value /= 0.0; + } catch (DivisionByZeroError $e) { + return $e->getMessage(); + } + return $value; +} + +function main(): void +{ + var_dump(divInt(7)); + var_dump(modInt(7)); + var_dump(divFloat(1.5)); + $n = std::int(9); + try { + $n /= 0; + } catch (DivisionByZeroError $e) { + var_dump($e->getMessage()); + } + var_dump($n); +} +?> +--EXPECT-- +string(16) "Division by zero" +string(14) "Modulo by zero" +string(16) "Division by zero" +string(16) "Division by zero" +int(9) diff --git a/tests/compiler/operator/typed-int-float-division.phpt b/tests/compiler/operator/typed-int-float-division.phpt new file mode 100644 index 00000000..872ab2f4 --- /dev/null +++ b/tests/compiler/operator/typed-int-float-division.phpt @@ -0,0 +1,41 @@ +--TEST-- +Typed int and float division follows PHP semantics (fractional result, DivisionByZeroError) +--FILE-- +getMessage() . "\n"; + } + var_dump(divFloats(7.0, 2.0)); + try { + divFloats(1.5, 0.0); + } catch (DivisionByZeroError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +} +?> +--EXPECT-- +float(3.5) +float(2) +float(9.223372036854776E+18) +caught: Division by zero +float(3.5) +caught: Division by zero diff --git a/tests/compiler/operator/typed-int-mod-shift.phpt b/tests/compiler/operator/typed-int-mod-shift.phpt new file mode 100644 index 00000000..5ad23fa6 --- /dev/null +++ b/tests/compiler/operator/typed-int-mod-shift.phpt @@ -0,0 +1,62 @@ +--TEST-- +Typed int modulo and shifts follow PHP semantics (errors, boundaries) +--FILE-- +> $b; +} + +function main(): void +{ + var_dump(modInts(7, 3)); + var_dump(modInts(-7, 3)); + var_dump(modInts(PHP_INT_MIN, -1)); + try { + modInts(7, 0); + } catch (DivisionByZeroError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + var_dump(shiftLeft(1, 3)); + var_dump(shiftLeft(1, 63)); + var_dump(shiftLeft(1, 64)); + try { + shiftLeft(1, -1); + } catch (ArithmeticError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + var_dump(shiftRight(-8, 1)); + var_dump(shiftRight(-8, 65)); + var_dump(shiftRight(8, 65)); + try { + shiftRight(1, -1); + } catch (ArithmeticError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +} +?> +--EXPECT-- +int(1) +int(-1) +int(0) +caught: Modulo by zero +int(8) +int(-9223372036854775808) +int(0) +caught: Bit shift by negative number +int(-4) +int(-1) +int(0) +caught: Bit shift by negative number diff --git a/tests/compiler/type_hits/native-type.phpt b/tests/compiler/type_hits/native-type.phpt index 4bf6ad02..e28929bf 100644 --- a/tests/compiler/type_hits/native-type.phpt +++ b/tests/compiler/type_hits/native-type.phpt @@ -38,5 +38,5 @@ bool(true) int(99) float(2026) float(2.5) -int(2) +float(2.5) float(10) \ No newline at end of file