diff --git a/phpunit/code/constant-overflow-warning.php b/phpunit/code/constant-overflow-warning.php new file mode 100644 index 00000000..e0800681 --- /dev/null +++ b/phpunit/code/constant-overflow-warning.php @@ -0,0 +1,10 @@ +> 2); +} diff --git a/phpunit/code/shift-boundary-native-negative.php b/phpunit/code/shift-boundary-native-negative.php new file mode 100644 index 00000000..e6de2d2b --- /dev/null +++ b/phpunit/code/shift-boundary-native-negative.php @@ -0,0 +1,9 @@ +> -1); +} diff --git a/phpunit/code/shift-boundary-native-ok.php b/phpunit/code/shift-boundary-native-ok.php new file mode 100644 index 00000000..2547a6ca --- /dev/null +++ b/phpunit/code/shift-boundary-native-ok.php @@ -0,0 +1,11 @@ +> 1); + var_dump(8 >> 2); +} diff --git a/phpunit/code/shift-boundary-native-overflow.php b/phpunit/code/shift-boundary-native-overflow.php new file mode 100644 index 00000000..08ea1c60 --- /dev/null +++ b/phpunit/code/shift-boundary-native-overflow.php @@ -0,0 +1,9 @@ +> 64; + $c = -1 >> 64; + $d = 1 >> -1; + $e = 1 << 2; + $f = 3 >> 1; +} diff --git a/phpunit/src/ConstantArithmeticOverflowTest.php b/phpunit/src/ConstantArithmeticOverflowTest.php new file mode 100644 index 00000000..917a98b5 --- /dev/null +++ b/phpunit/src/ConstantArithmeticOverflowTest.php @@ -0,0 +1,70 @@ +compileWithReporter(); + + $overflowWarnings = array_values(array_filter( + $reporter->warnings, + fn (string $message): bool => str_contains($message, 'Constant integer arithmetic overflows int64') + )); + $this->assertCount(2, $overflowWarnings); + $this->assertStringContainsString('9223372036854775807 + 1', $overflowWarnings[0]); + $this->assertStringContainsString('folding to PHP float result', $overflowWarnings[0]); + } + + public function testNonOverflowingConstantArithmeticDoesNotWarn(): void + { + $reporter = $this->compileWithReporter(); + + foreach ($reporter->warnings as $message) { + $this->assertStringNotContainsString('1 + 2', $message); + } + } + + /** + * @return object{warnings: list} + */ + private function compileWithReporter(): object + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $reporter = new class implements DiagnosticReporter { + /** @var list */ + public array $warnings = []; + + public function fatal(string $message): never + { + throw new TestError($message); + } + + public function warning(Node $node, string $file, string $message): void + { + $this->warnings[] = $message; + } + }; + $compiler->setDiagnosticReporter($reporter); + + $testFile = __DIR__ . '/../code/constant-overflow-warning.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $compiler->convertFile($testFile); + + return $reporter; + } +} diff --git a/phpunit/src/ShiftBoundaryTest.php b/phpunit/src/ShiftBoundaryTest.php new file mode 100644 index 00000000..3f7ea29e --- /dev/null +++ b/phpunit/src/ShiftBoundaryTest.php @@ -0,0 +1,149 @@ +compileWithReporter(); + + $overflowWarnings = array_values(array_filter( + $reporter->warnings, + fn (string $message): bool => str_contains($message, 'Bit shift count 64 is >= 64') + )); + $this->assertCount(3, $overflowWarnings); + foreach ($overflowWarnings as $message) { + $this->assertStringContainsString('folding with PHP semantics', $message); + } + } + + public function testNegativeShiftCountEmitsWarning(): void + { + $reporter = $this->compileWithReporter(); + + $negativeWarnings = array_values(array_filter( + $reporter->warnings, + fn (string $message): bool => str_contains($message, 'Bit shift by a negative number') + )); + $this->assertCount(1, $negativeWarnings); + $this->assertStringContainsString('ArithmeticError', $negativeWarnings[0]); + } + + public function testInRangeShiftDoesNotWarn(): void + { + $reporter = $this->compileWithReporter(); + + foreach ($reporter->warnings as $message) { + $this->assertStringNotContainsString('Bit shift count 2 is >=', $message); + $this->assertStringNotContainsString('Bit shift count 1 is >=', $message); + } + } + + public function testNativeModeRejectsShiftCountAtLeastWordSize(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Bit shift count 64 is >= 64 and is not supported in native mode'); + $this->compileNativeWithReporter('shift-boundary-native-overflow.php'); + } + + public function testNativeModeRejectsNegativeShiftCount(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Bit shift by a negative number is not supported in native mode'); + $this->compileNativeWithReporter('shift-boundary-native-negative.php'); + } + + public function testNativeModeRejectsNegativeRightShift(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Right shift of a negative value is implementation-defined in C++'); + $this->compileNativeWithReporter('shift-boundary-native-neg-right.php'); + } + + public function testNativeModeRejectsLeftShiftChangingSignBit(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Left shift that changes the sign bit is undefined behavior in C++'); + $this->compileNativeWithReporter('shift-boundary-native-sign-bit.php'); + } + + public function testNativeModeRejectsNegativeLeftShift(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Left shift of a negative value is undefined behavior in C++'); + $this->compileNativeWithReporter('shift-boundary-native-neg-left.php'); + } + + public function testNativeModeAllowsInRangeShift(): void + { + $compiler = $this->compileNativeWithReporter('shift-boundary-native-ok.php'); + $this->assertNotNull($compiler); + } + + /** + * @return object{warnings: list} + */ + private function compileWithReporter(): object + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $reporter = new class implements DiagnosticReporter { + /** @var list */ + public array $warnings = []; + + public function fatal(string $message): never + { + throw new TestError($message); + } + + public function warning(Node $node, string $file, string $message): void + { + $this->warnings[] = $message; + } + }; + $compiler->setDiagnosticReporter($reporter); + + $testFile = __DIR__ . '/../code/shift-boundary-warning.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $compiler->convertFile($testFile); + + return $reporter; + } + + private function compileNativeWithReporter(string $file): CompilerTest + { + global $translator; + $compiler = CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $compiler->setDiagnosticReporter(new class implements DiagnosticReporter { + public function fatal(string $message): never + { + throw new TestError($message); + } + + public function warning(Node $node, string $file, string $message): void + { + } + }); + + $testFile = __DIR__ . '/../code/' . $file; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $compiler->convertFile($testFile); + + return $compiler; + } +} diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index e40cb3ef..9f3719e7 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -132,6 +132,13 @@ trait BinaryOpTrait return 'php::fn::mod(' . $leftExpr . ', ' . $rightExpr . ')'; } + if ($op === '<<' || $op === '>>') { + $foldedShift = $this->tryFoldConstantShift($left, $right, $op, $leftExpr, $rightExpr); + if ($foldedShift !== null) { + return $foldedShift; + } + } + $folded = $this->tryFoldConstantIntArithmetic($left, $right, $op); if ($folded !== null) { return $folded; @@ -140,6 +147,104 @@ trait BinaryOpTrait return '((' . $leftExpr . ') ' . $op . ' (' . $rightExpr . '))'; } + /** + * Fold constant integer shifts to PHP semantics in non-native mode. + * + * PHP shifts by >= word size to 0 (left) or -1/0 (right, arithmetic), and + * throws a catchable ArithmeticError for negative shift counts. Native C++ + * shifts are undefined for those counts, so the constant case is folded + * (>= word size) or routed through php::Var (negative, so the Zend shift + * function raises the catchable error at runtime). + */ + protected function tryFoldConstantShift( + NodeAbstract $left, + NodeAbstract $right, + string $op, + string $leftExpr, + string $rightExpr + ): ?string { + $leftValue = $this->constantIntValue($left); + $shiftValue = $this->constantIntValue($right); + if ($leftValue === null || $shiftValue === null) { + return null; + } + + $wordSize = PHP_INT_SIZE * 8; + + if ($this->nativeTypes) { + if ($shiftValue >= $wordSize) { + $this->fatalError( + $right, + 'Bit shift count ' . $shiftValue . ' is >= ' . $wordSize + . ' and is not supported in native mode' + ); + } + if ($shiftValue < 0) { + $this->fatalError( + $right, + 'Bit shift by a negative number is not supported in native mode' + ); + } + if ($op === '>>' && $leftValue < 0) { + $this->fatalError( + $left, + 'Right shift of a negative value is implementation-defined in C++' + . ' and is not supported in native mode' + ); + } + if ($op === '<<' && $leftValue < 0) { + $this->fatalError( + $left, + 'Left shift of a negative value is undefined behavior in C++' + . ' and is not supported in native mode' + ); + } + if ($op === '<<' && $this->leftShiftTouchesSignBit($leftValue, $shiftValue)) { + $this->fatalError( + $left, + 'Left shift that changes the sign bit is undefined behavior in C++' + . ' and is not supported in native mode' + ); + } + return null; + } + + if ($shiftValue >= $wordSize) { + $result = $op === '<<' ? '0LL' : ($leftValue < 0 ? '-1LL' : '0LL'); + $this->warning( + $right, + 'Bit shift count ' . $shiftValue . ' is >= ' . $wordSize + . '; folding with PHP semantics (left shift to 0, right shift to -1 for negative operands, 0 otherwise)' + ); + return $result; + } + + if ($shiftValue < 0) { + $this->warning( + $right, + 'Bit shift by a negative number throws ArithmeticError at runtime' + ); + // Route through php::Var so the Zend shift function raises the catchable error. + return '((php::Var(' . $leftExpr . ')) ' . $op . ' (php::Var(' . $rightExpr . ')))'; + } + + return null; + } + + /** + * Whether a constant left shift of a non-negative value would set the sign + * bit (overflow the signed range), which is undefined behavior in C++. + */ + protected function leftShiftTouchesSignBit(int $value, int $shift): bool + { + if ($value < 0 || $shift <= 0) { + return false; + } + // PHP shift is performed on unsigned values; a negative result means + // the sign bit got set. + return ($value << $shift) < 0; + } + /** * Fold constant int arithmetic that would overflow int64 in generated C++. * @@ -181,6 +286,11 @@ trait BinaryOpTrait return null; } + $this->warning( + $left, + 'Constant integer arithmetic overflows int64; folding to PHP float result (' + . $leftValue . ' ' . $op . ' ' . $rightValue . ')' + ); return $this->genFloatLiteral($result); } diff --git a/tests/compiler/constant-shift-boundaries.phpt b/tests/compiler/constant-shift-boundaries.phpt new file mode 100644 index 00000000..3ea27138 --- /dev/null +++ b/tests/compiler/constant-shift-boundaries.phpt @@ -0,0 +1,33 @@ +--TEST-- +Constant bit shift boundaries follow PHP semantics +--FILE-- +> 64); + var_dump(-1 >> 64); + var_dump(PHP_INT_MIN >> 64); + var_dump(1 >> 63); + var_dump(-1 >> 63); + var_dump(1 << 63); + var_dump(5 >> 1); + try { + var_dump(1 >> -1); + } catch (ArithmeticError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +} +?> +--EXPECTF-- +int(0) +int(0) +int(-1) +int(-1) +int(0) +int(-1) +int(-9223372036854775808) +int(2) +caught: Bit shift by negative number