From b1237f0f771d53d73380d37708d4f7e05b479c5d Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 12:28:55 +0200 Subject: [PATCH 1/5] fix(codegen): route typed division, int modulo and shifts through PHP operators Typed int/int and float-typed division fell through to a raw C++ '/': 7 / 2 on zend_long operands truncated to 3 where PHP returns 3.5, integer division by zero was undefined behavior and float division by zero produced INF, while PHP raises a catchable DivisionByZeroError in both cases; PHP_INT_MIN / -1 also has UB in C++ but promotes to float in PHP. The '%' guard only routed through php::fn::mod when NOT both operands were int, so both-int modulo kept raw C++ '%' (UB for a zero divisor and for PHP_INT_MIN % -1, which PHP defines as 0). Dynamic int shifts were raw C++ too: PHP defines counts >= the word size as 0 (or -1 for negative right shifts) and raises ArithmeticError for negative counts, both undefined in C++. Route all of these through the encapsulated php::Var operators / php::fn::mod in non-native mode, matching the existing +/-/* pattern. Constant folds are untouched; constant shifts that C++ defines identically to PHP still emit raw operators. --- .../code/typed-scalar-arithmetic-codegen.php | 26 ++++++++ .../src/TypedScalarArithmeticCodegenTest.php | 55 ++++++++++++++++ src/Parser/BinaryOpTrait.php | 58 ++++++++++++++++- .../operator/typed-int-float-division.phpt | 41 ++++++++++++ .../operator/typed-int-mod-shift.phpt | 62 +++++++++++++++++++ 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 phpunit/code/typed-scalar-arithmetic-codegen.php create mode 100644 phpunit/src/TypedScalarArithmeticCodegenTest.php create mode 100644 tests/compiler/operator/typed-int-float-division.phpt create mode 100644 tests/compiler/operator/typed-int-mod-shift.phpt 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/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/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index df1b73ed..190705b0 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -141,8 +141,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 === '>>') { @@ -150,6 +162,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); @@ -177,6 +213,24 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string return '((php::Var(' . $leftExpr . ')) ' . $op . ' (' . $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 . '))'; } 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 From 6c903d16610af604b6815e92cb5e2226c2c8e530 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 12:51:55 +0200 Subject: [PATCH 2/5] fix(parser): defer literal zero divisors to the runtime DivisionByZeroError A literal `/ 0` or `% 0` (including `/=` and `%=`) was a compile-time fatal, rejecting valid PHP: Zend compiles it and raises a catchable DivisionByZeroError only when the statement executes, so dead or guarded code like `if ($cond) { $x = 1 % 0; }` must compile. The equivalent spellings `1 % (1 - 1)` and `10 / ZERO` were already accepted and lowered to the catchable runtime error. Give the literal spelling the same lowering: route the operation through the encapsulated Variant operators (compound assignments on Variant slots already defer via operator/= and operator%=), keep a compile-time warning in normal mode, and keep the fatal in native mode where the C++ operation would be undefined behavior. The six OperatorTest cases asserting the old compile-time fatal now assert the runtime-error lowering instead. --- phpunit/src/OperatorTest.php | 51 +++++++++++++----- src/Parser/BinaryOpTrait.php | 24 ++++++--- .../literal-division-by-zero-runtime.phpt | 54 +++++++++++++++++++ 3 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 tests/compiler/operator/literal-division-by-zero-runtime.phpt diff --git a/phpunit/src/OperatorTest.php b/phpunit/src/OperatorTest.php index 0e569f66..8ee31577 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->assertStringContainsString('((php::Var(10LL)) / (php::Var(0LL)))', $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->assertStringContainsString('((php::Var(10LL)) % (php::Var(0LL)))', $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/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index 190705b0..02aed704 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -577,21 +577,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 . ')))'; } @@ -1318,7 +1322,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) From 13400e5efec670577a51219489577b1d42bc6905 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 11:56:03 +0200 Subject: [PATCH 3/5] test(operator): platform-neutral literal suffixes, PHP division on typed ints The literal-division assertions hardcoded the macOS zend_long suffix (LL); Linux emits L, so they now match either. native-type.phpt asserted the truncating int division this change removes: division on typed int operands follows PHP semantics in non-native mode, consistent with the pre-existing + - * routing (use native_types keeps raw division), so std::int(10) / 4 is now float(2.5). --- phpunit/src/OperatorTest.php | 4 ++-- tests/compiler/type_hits/native-type.phpt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phpunit/src/OperatorTest.php b/phpunit/src/OperatorTest.php index 8ee31577..82f59dfe 100644 --- a/phpunit/src/OperatorTest.php +++ b/phpunit/src/OperatorTest.php @@ -65,7 +65,7 @@ public function testDynamicBoolCallInLogicalExpressionIsConvertedToNativeBool(): public function testLiteralIntDivideByZeroCompilesToRuntimeError(): void { $cpp = $this->compileToCpp('divide-by-zero-int.php'); - $this->assertStringContainsString('((php::Var(10LL)) / (php::Var(0LL)))', $cpp); + $this->assertMatchesRegularExpression('/\(\(php::Var\(10L{1,2}\)\) \/ \(php::Var\(0L{1,2}\)\)\)/', $cpp); } public function testLiteralFloatDivideByZeroCompilesToRuntimeError(): void @@ -84,7 +84,7 @@ public function testLiteralStringDivideByZeroCompilesToRuntimeError(): void public function testLiteralModuloByZeroCompilesToRuntimeError(): void { $cpp = $this->compileToCpp('modulo-by-zero-int.php'); - $this->assertStringContainsString('((php::Var(10LL)) % (php::Var(0LL)))', $cpp); + $this->assertMatchesRegularExpression('/\(\(php::Var\(10L{1,2}\)\) % \(php::Var\(0L{1,2}\)\)\)/', $cpp); } public function testLiteralDivideAssignByZeroCompilesToRuntimeError(): void 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 From 9b35c2dcf68f92c689a70785de19d591a3bf95aa Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 15:41:17 +0200 Subject: [PATCH 4/5] fix(codegen): literal zero divisors on native scalar slots raise the runtime error Downgrading the literal-zero compile fatal to a warning exposed the raw C++ compound path on typed native slots: `int $value; $value /= 0` compiled to `value /= php::toInt(0L)` and killed the process with SIGFPE instead of the catchable DivisionByZeroError (`%= 0` likewise; float `/= 0.0` produced INF). A proven zero divisor always throws before any assignment happens, so the whole compound lowers to the PHP-semantics binary operation through php::Var and the target is left untouched. Native-types mode keeps the compile-time rejection. --- src/Parser/AssignOpTrait.php | 18 +++++++ .../literal-division-by-zero-typed-slots.phpt | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/compiler/operator/literal-division-by-zero-typed-slots.phpt diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index dd7731a5..553c6ba9 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -868,6 +868,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/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) From aa300250839283268531bee220365b9c0d5aec73 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 09:42:37 +0200 Subject: [PATCH 5/5] fix(codegen): exclude explicit native scalars from PHP arithmetic routing std::int()/std::float() opt into native C++ arithmetic independently of the file-wide native_types declaration, and the existing + - * routing already honors that via isExplicitNativeArithmeticExpr(). The new division, both-int modulo and dynamic shift branches, and the literal-zero compound lowering, bypassed it: std::int(10) / 4 changed from int(2) to float(2.5) and native-type.phpt was updated to encode the regression. Every new PHP-semantics branch now skips explicitly native operands, native-type.phpt is restored to int(2), and a proven zero divisor on an explicit native slot keeps the compile-time rejection used by native_types mode instead of being silently rerouted to PHP semantics. Boundary coverage added on both sides: ordinary typed parameters keep PHP behavior (7 / 2 is 3.5, -7 % 2 is -1) while std::int()/std::float() keep native division, modulo and shifts. --- phpunit/code/native_slot_zero_divisor.php | 7 ++++++ phpunit/src/NativeSlotZeroDivisorTest.php | 14 +++++++++++ src/Parser/AssignOpTrait.php | 7 ++++++ src/Parser/BinaryOpTrait.php | 6 +++++ .../literal-division-by-zero-typed-slots.phpt | 9 ------- .../typed-vs-explicit-native-arith.phpt | 25 +++++++++++++++++++ tests/compiler/type_hits/native-type.phpt | 2 +- 7 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 phpunit/code/native_slot_zero_divisor.php create mode 100644 phpunit/src/NativeSlotZeroDivisorTest.php create mode 100644 tests/compiler/operator/typed-vs-explicit-native-arith.phpt diff --git a/phpunit/code/native_slot_zero_divisor.php b/phpunit/code/native_slot_zero_divisor.php new file mode 100644 index 00000000..6cc7186b --- /dev/null +++ b/phpunit/code/native_slot_zero_divisor.php @@ -0,0 +1,7 @@ +exec('Cannot divide or modulo by zero', 'native_slot_zero_divisor.php'); + } +} diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 553c6ba9..258a948f 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -881,6 +881,13 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string && $this->hasVar((string) $this->parseIdentifier($node->var)) && in_array($this->detectVarType($node->var), [Type::INT, Type::FLOAT], true) ) { + // std::int()/std::float() values are an explicit opt-in to native + // C++ arithmetic; changing them to PHP semantics here would be as + // wrong as the undefined raw operation. Keep the compile-time + // rejection native_types mode uses. + if ($this->isExplicitNativeArithmeticExpr($node->var)) { + $this->fatalError($node->expr, 'Cannot divide or modulo by zero'); + } $binOp = $op === '/=' ? '/' : '%'; return '((php::Var(' . $this->parseExprAsValue($node->var) . ')) ' . $binOp . ' (php::Var(' . $this->parseExprAsValue($node->expr) . ')))'; diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index 02aed704..5325d2ce 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -151,6 +151,8 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string // the PHP mod function unless the user explicitly selected // `use native_types`. Constant operands are folded below. if (!$this->nativeTypes + && !$this->isExplicitNativeArithmeticExpr($left) + && !$this->isExplicitNativeArithmeticExpr($right) && $this->evaluateConstantIntArithmetic($left, $right, '%') === null ) { return 'php::fn::mod(' . $leftExpr . ', ' . $rightExpr . ')'; @@ -171,6 +173,8 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string // operators unless the user explicitly selected `use native_types`. // Constant shifts that C++ defines identically to PHP stay raw. if (!$this->nativeTypes + && !$this->isExplicitNativeArithmeticExpr($left) + && !$this->isExplicitNativeArithmeticExpr($right) && $leftType === Type::INT && $rightType === Type::INT ) { @@ -223,6 +227,8 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string // above or are exact when emitted directly. if (!$this->nativeTypes && $op === '/' + && !$this->isExplicitNativeArithmeticExpr($left) + && !$this->isExplicitNativeArithmeticExpr($right) && in_array($leftType, [Type::INT, Type::FLOAT], true) && in_array($rightType, [Type::INT, Type::FLOAT], true) && ($this->constantNumericValue($left, false) === null diff --git a/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt b/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt index 45fd856b..c9f9f16b 100644 --- a/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt +++ b/tests/compiler/operator/literal-division-by-zero-typed-slots.phpt @@ -37,18 +37,9 @@ 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-vs-explicit-native-arith.phpt b/tests/compiler/operator/typed-vs-explicit-native-arith.phpt new file mode 100644 index 00000000..d617c02a --- /dev/null +++ b/tests/compiler/operator/typed-vs-explicit-native-arith.phpt @@ -0,0 +1,25 @@ +--TEST-- +Ordinary typed scalars use PHP arithmetic; std::int()/std::float() stay native +--FILE-- + +--EXPECT-- +float(3.5) +int(-1) +int(2) +float(2.5) diff --git a/tests/compiler/type_hits/native-type.phpt b/tests/compiler/type_hits/native-type.phpt index e28929bf..4bf6ad02 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) -float(2.5) +int(2) float(10) \ No newline at end of file