fix(codegen): PHP semantics for division, modulo, shifts and compound assignment on typed scalars (#45) --skip-tests

* 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.

* 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.

* 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).

* 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.

* 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.
master
Alessio Giacobbe 1 day ago committed by GitHub
parent 48c95e9f9b
commit 8f8ae77ec8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      phpunit/code/native_slot_zero_divisor.php
  2. 26
      phpunit/code/typed-scalar-arithmetic-codegen.php
  3. 14
      phpunit/src/NativeSlotZeroDivisorTest.php
  4. 51
      phpunit/src/OperatorTest.php
  5. 55
      phpunit/src/TypedScalarArithmeticCodegenTest.php
  6. 25
      src/Parser/AssignOpTrait.php
  7. 88
      src/Parser/BinaryOpTrait.php
  8. 54
      tests/compiler/operator/literal-division-by-zero-runtime.phpt
  9. 45
      tests/compiler/operator/literal-division-by-zero-typed-slots.phpt
  10. 41
      tests/compiler/operator/typed-int-float-division.phpt
  11. 62
      tests/compiler/operator/typed-int-mod-shift.phpt
  12. 25
      tests/compiler/operator/typed-vs-explicit-native-arith.phpt

@ -0,0 +1,7 @@
<?php
function main(): void
{
$n = std::int(9);
$n /= 0;
var_dump($n);
}

@ -0,0 +1,26 @@
<?php
function divTypedInts(int $a, int $b): float
{
return $a / $b;
}
function divTypedFloats(float $a, float $b): float
{
return $a / $b;
}
function modTypedInts(int $a, int $b): int
{
return $a % $b;
}
function shiftLeftTypedInts(int $a, int $b): int
{
return $a << $b;
}
function shiftRightTypedInts(int $a, int $b): int
{
return $a >> $b;
}

@ -0,0 +1,14 @@
<?php
/**
* std::int()/std::float() opt into native C++ arithmetic, so a proven zero
* divisor cannot be silently rerouted to PHP semantics; it keeps the
* compile-time rejection that native_types mode uses.
*/
class NativeSlotZeroDivisorTest extends BaseTest
{
public function testExplicitNativeSlotZeroDivisorIsRejected(): void
{
$this->exec('Cannot divide or modulo by zero', 'native_slot_zero_divisor.php');
}
}

@ -56,34 +56,61 @@ class OperatorTest extends \BaseTest
$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

@ -0,0 +1,55 @@
<?php
use TypePhp\CompilerTest;
/**
* Typed int/float division, int modulo and int shifts must not be emitted as
* raw C++ operators: raw zend_long division truncates (PHP: 7 / 2 === 3.5),
* a zero divisor must raise the catchable DivisionByZeroError instead of
* being undefined behavior (int) or INF (float), and out-of-range shift
* counts are undefined behavior in C++ while PHP defines them.
*/
final class TypedScalarArithmeticCodegenTest extends \BaseTest
{
public function testTypedIntDivisionRoutesThroughVariant(): void
{
$code = $this->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;
}
}

@ -868,6 +868,31 @@ trait AssignOpTrait
$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)
) {
// 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) . ')))';
}
if ($node->var instanceof Expr\PropertyFetch && $this->isNativeObjectPropertyHook($node->var)) {
$this->fatalError(
$node->var,

@ -141,8 +141,22 @@ trait BinaryOpTrait
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->isExplicitNativeArithmeticExpr($left)
&& !$this->isExplicitNativeArithmeticExpr($right)
&& $this->evaluateConstantIntArithmetic($left, $right, '%') === null
) {
return 'php::fn::mod(' . $leftExpr . ', ' . $rightExpr . ')';
}
}
if ($op === '<<' || $op === '>>') {
@ -150,6 +164,32 @@ trait BinaryOpTrait
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
&& !$this->isExplicitNativeArithmeticExpr($left)
&& !$this->isExplicitNativeArithmeticExpr($right)
&& $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 +217,26 @@ trait BinaryOpTrait
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 === '/'
&& !$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
|| $this->constantNumericValue($right, false) === null)
) {
return '((php::Var(' . $leftExpr . ')) / (php::Var(' . $rightExpr . ')))';
}
return '((' . $leftExpr . ') ' . $op . ' (' . $rightExpr . '))';
}
@ -523,21 +583,25 @@ trait BinaryOpTrait
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 . ')))';
}
@ -1264,7 +1328,13 @@ trait BinaryOpTrait
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');
}
}

@ -0,0 +1,54 @@
--TEST--
Literal zero divisors compile and raise catchable DivisionByZeroError at runtime
--FILE--
<?php
declare(strict_types=1);
function main(): void
{
$cond = false;
if ($cond) {
$x = 1 % 0;
var_dump($x);
}
echo "dead code ok\n";
try {
$y = 10 / 0;
var_dump($y);
} catch (DivisionByZeroError $e) {
echo "caught: " . $e->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)

@ -0,0 +1,45 @@
--TEST--
Literal zero divisors on typed native slots raise catchable DivisionByZeroError
--FILE--
<?php
function divInt(int $value): mixed
{
try {
$value /= 0;
} catch (DivisionByZeroError $e) {
return $e->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));
}
?>
--EXPECT--
string(16) "Division by zero"
string(14) "Modulo by zero"
string(16) "Division by zero"

@ -0,0 +1,41 @@
--TEST--
Typed int and float division follows PHP semantics (fractional result, DivisionByZeroError)
--FILE--
<?php
declare(strict_types=1);
function divInts(int $a, int $b): float
{
return $a / $b;
}
function divFloats(float $a, float $b): float
{
return $a / $b;
}
function main(): void
{
var_dump(divInts(7, 2));
var_dump(divInts(6, 3));
var_dump(divInts(PHP_INT_MIN, -1));
try {
divInts(7, 0);
} catch (DivisionByZeroError $e) {
echo "caught: " . $e->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

@ -0,0 +1,62 @@
--TEST--
Typed int modulo and shifts follow PHP semantics (errors, boundaries)
--FILE--
<?php
declare(strict_types=1);
function modInts(int $a, int $b): int
{
return $a % $b;
}
function shiftLeft(int $a, int $b): int
{
return $a << $b;
}
function shiftRight(int $a, int $b): int
{
return $a >> $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

@ -0,0 +1,25 @@
--TEST--
Ordinary typed scalars use PHP arithmetic; std::int()/std::float() stay native
--FILE--
<?php
function phpDiv(int $a, int $b): mixed { return $a / $b; }
function phpMod(int $a, int $b): mixed { return $a % $b; }
function main(): void
{
// Ordinary typed parameters follow PHP semantics.
var_dump(phpDiv(7, 2));
var_dump(phpMod(-7, 2));
// Explicit native scalars opt into C++ semantics.
$i = std::int(10);
var_dump($i / 4);
$f = std::float(10.0);
var_dump($f / 4);
}
?>
--EXPECT--
float(3.5)
int(-1)
int(2)
float(2.5)
Loading…
Cancel
Save