diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 1eeae725..8506cc64 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -5,6 +5,21 @@ use TypePhp\Exception\TestError; require __DIR__ . '/../bin/bootstrap.php'; + +// The vendor directory may be shared between checkouts (e.g. a git worktree +// with a symlinked vendor/). Composer's autoloader resolves TypePhp\ against +// the checkout that owns vendor/, which would silently test another tree's +// sources. Prepend a loader anchored to THIS checkout so the test suite always +// exercises the code it ships with. +spl_autoload_register(static function (string $class): void { + if (str_starts_with($class, 'TypePhp\\')) { + $path = dirname(__DIR__) . '/src/' . str_replace('\\', '/', substr($class, strlen('TypePhp\\'))) . '.php'; + if (is_file($path)) { + require $path; + } + } +}, true, true); + require_once __DIR__ . '/../src/polyfills.php'; require __DIR__ . '/../src/gen_stub.php'; diff --git a/phpunit/code/abstract_redeclare_concrete.php b/phpunit/code/abstract_redeclare_concrete.php new file mode 100644 index 00000000..cdc6b77d --- /dev/null +++ b/phpunit/code/abstract_redeclare_concrete.php @@ -0,0 +1,5 @@ +run(); + } +} + +class Job +{ + use JobTrait; + + private function run(): void {} +} + +function main() {} diff --git a/phpunit/code/coalesce-assign-side-effect-codegen.php b/phpunit/code/coalesce-assign-side-effect-codegen.php new file mode 100644 index 00000000..be0ec0ce --- /dev/null +++ b/phpunit/code/coalesce-assign-side-effect-codegen.php @@ -0,0 +1,21 @@ +value; } public function __invoke(): string { return $this->label(); } } + +function main() {} diff --git a/phpunit/code/eval-order-side-effects.php b/phpunit/code/eval-order-side-effects.php new file mode 100644 index 00000000..ad2e9f9a --- /dev/null +++ b/phpunit/code/eval-order-side-effects.php @@ -0,0 +1,24 @@ + 1; } } + +function main() {} diff --git a/phpunit/code/hook_rule_abstract_no_hooks.php b/phpunit/code/hook_rule_abstract_no_hooks.php new file mode 100644 index 00000000..2f4af5b1 --- /dev/null +++ b/phpunit/code/hook_rule_abstract_no_hooks.php @@ -0,0 +1,4 @@ + 1; } } + +function main() {} diff --git a/phpunit/code/hook_rule_readonly_class.php b/phpunit/code/hook_rule_readonly_class.php new file mode 100644 index 00000000..e6b4184d --- /dev/null +++ b/phpunit/code/hook_rule_readonly_class.php @@ -0,0 +1,4 @@ + 1; } } + +function main() {} diff --git a/phpunit/code/hook_rule_static.php b/phpunit/code/hook_rule_static.php new file mode 100644 index 00000000..5b48c210 --- /dev/null +++ b/phpunit/code/hook_rule_static.php @@ -0,0 +1,4 @@ + 1; } } + +function main() {} diff --git a/phpunit/code/hook_rule_valid.php b/phpunit/code/hook_rule_valid.php new file mode 100644 index 00000000..02e0cf52 --- /dev/null +++ b/phpunit/code/hook_rule_valid.php @@ -0,0 +1,4 @@ + $this->b; set { $this->b = $value; } } abstract public string $s { get; } } + +function main() {} diff --git a/phpunit/code/inheritance_error_prop_readonly.php b/phpunit/code/inheritance_error_prop_readonly.php index f9bca1e0..0e54ffd3 100644 --- a/phpunit/code/inheritance_error_prop_readonly.php +++ b/phpunit/code/inheritance_error_prop_readonly.php @@ -6,7 +6,7 @@ class A class B extends A { - public readonly int $x = 2; + public readonly int $x; } function main() {} diff --git a/phpunit/code/int-min-constant-metadata.php b/phpunit/code/int-min-constant-metadata.php new file mode 100644 index 00000000..6ec9de18 --- /dev/null +++ b/phpunit/code/int-min-constant-metadata.php @@ -0,0 +1,16 @@ +helper(); + } +} + +class B extends A +{ + // Any signature is allowed: private methods are not inherited. + public function helper(int $n = 0): int + { + return $n; + } +} + +class C +{ + final private function locked(): void {} +} + +// Zend ignores FINAL on non-constructor private methods (declaring one only +// warns), so a child may still redeclare it. +class D extends C +{ + public function locked(): void {} +} + +class E +{ + private static function make(): int + { + return 1; + } +} + +class F extends E +{ + public static function make(): string + { + return 'f'; + } +} + +function main() {} diff --git a/phpunit/code/promotion_rule_variadic.php b/phpunit/code/promotion_rule_variadic.php new file mode 100644 index 00000000..7625607f --- /dev/null +++ b/phpunit/code/promotion_rule_variadic.php @@ -0,0 +1,4 @@ +port = 80; } } + +function main() {} diff --git a/phpunit/code/trait_adaptations_valid.php b/phpunit/code/trait_adaptations_valid.php new file mode 100644 index 00000000..5e458716 --- /dev/null +++ b/phpunit/code/trait_adaptations_valid.php @@ -0,0 +1,46 @@ +> $b; +} diff --git a/phpunit/code/unary-minus-codegen.php b/phpunit/code/unary-minus-codegen.php new file mode 100644 index 00000000..c8d60f4b --- /dev/null +++ b/phpunit/code/unary-minus-codegen.php @@ -0,0 +1,11 @@ +exec('Abstract function `Job::run()` cannot contain body', 'abstract_rule_body.php'); + } + + public function testAbstractClassMethodCannotBePrivate(): void + { + $this->exec('Abstract function `Job::run()` cannot be declared private', 'abstract_rule_private.php'); + } + + public function testAbstractPrivateTraitMethodIsAllowed(): void + { + $this->compile('abstract_rule_private_trait_valid.php'); + } +} diff --git a/phpunit/src/AbstractRedeclarationTest.php b/phpunit/src/AbstractRedeclarationTest.php new file mode 100644 index 00000000..40975655 --- /dev/null +++ b/phpunit/src/AbstractRedeclarationTest.php @@ -0,0 +1,39 @@ +exec( + 'Cannot make non abstract method `A::f()` abstract in class `B`', + 'abstract_redeclare_concrete.php' + ); + } + + public function testAbstractRedeclarationMustStayCompatible(): void + { + $this->exec( + 'Declaration of `B::f()` must be compatible with `A::f()`', + 'abstract_redeclare_incompatible.php' + ); + } + + public function testConcreteBuiltinMethodCannotBeMadeAbstract(): void + { + $this->exec( + 'Cannot make non abstract method `ArrayObject::count()` abstract in class `B`', + 'abstract_redeclare_internal.php' + ); + } + + public function testValidAbstractRedeclarations(): void + { + $this->compile('abstract_redeclare_valid.php'); + } +} diff --git a/phpunit/src/ClassKindInheritanceTest.php b/phpunit/src/ClassKindInheritanceTest.php new file mode 100644 index 00000000..2ca6ddf6 --- /dev/null +++ b/phpunit/src/ClassKindInheritanceTest.php @@ -0,0 +1,37 @@ +exec( + 'Non-readonly class `B` cannot extend readonly class `A`', + 'readonly_class_extends.php' + ); + } + + public function testReadonlyCannotExtendNonReadonly(): void + { + $this->exec( + 'Readonly class `B` cannot extend non-readonly class `A`', + 'readonly_class_extends_rev.php' + ); + } + + public function testInterfaceCannotExtendClass(): void + { + $this->exec( + 'cannot implement `A` - it is not an interface', + 'interface_extends_class.php' + ); + } + + public function testReadonlyExtendsReadonlyIsValid(): void + { + $this->compile('readonly_class_extends_valid.php'); + } +} diff --git a/phpunit/src/ClassTest.php b/phpunit/src/ClassTest.php index b2fd3126..f0028df3 100644 --- a/phpunit/src/ClassTest.php +++ b/phpunit/src/ClassTest.php @@ -730,9 +730,12 @@ public function testNewAbstractClass() $this->exec('abstract class `AbstractBase` cannot be instantiated', 'abstract-class-new.php'); } - public function testOverridePrivateMethod() + public function testPrivateMethodMayBeRedeclared() { - $this->exec('Cannot override private method `Base::doWork()`', 'override-private-method.php'); + // Private methods are not inherited: Zend lets a child redeclare one + // with any signature. Private calls bind to the declaring class's + // copy, so each class keeps its own implementation. + $this->compile('override-private-method.php'); } public function testPromotedAsymmetricPropertyRequiresType(): void @@ -776,12 +779,11 @@ public function testCannotAccessPrivateParentMethodFromTrait() $this->exec('Cannot access private method `BaseSecret::secret()`', 'trait-parent-method-private.php'); } - public function testComposedTraitMethodCannotShadowPrivateParentMethod() + public function testComposedTraitMethodMayShadowPrivateParentMethod() { - $this->exec( - 'Cannot override private method `PrivateMethodParent::execute()`', - 'trait-method-shadows-private.php' - ); + // A trait-composed method redeclaring a parent's PRIVATE method is + // valid in Zend, like any other private redeclaration. + $this->compile('trait-method-shadows-private.php'); } public function testSelfCanBePartOfUnionType() diff --git a/phpunit/src/CoalesceAssignSideEffectCodegenTest.php b/phpunit/src/CoalesceAssignSideEffectCodegenTest.php new file mode 100644 index 00000000..04836839 --- /dev/null +++ b/phpunit/src/CoalesceAssignSideEffectCodegenTest.php @@ -0,0 +1,64 @@ +compileFixture(); + + $body = $this->extractFunctionBody($code, 'php::Int php_coalescecompoundrhs()'); + + // The call must appear after the early-return isset guard of the + // conditional lambda, not as a plain statement before it. + $callPos = strpos($body, 'php_sideeffectcall()'); + self::assertIsInt($callPos); + $guardPos = strpos($body, 'if (php::exists(target)) { return target; }'); + self::assertIsInt($guardPos, 'expected the isset guard inside a conditional lambda'); + self::assertGreaterThan($guardPos, $callPos, 'RHS call must be inside the not-set branch'); + } + + public function testSimpleRhsKeepsPlainConditionalExpression(): void + { + $code = $this->compileFixture(); + + $body = $this->extractFunctionBody($code, 'php::Int php_coalescesimplerhs()'); + + self::assertStringContainsString( + '(php::exists(target)?target:(target = php_sideeffectcall()))', + $body, + ); + } + + private function extractFunctionBody(string $code, string $signature): string + { + $start = strpos($code, $signature); + self::assertIsInt($start, "missing function: {$signature}"); + $end = strpos($code, "\n}", $start); + self::assertIsInt($end); + return substr($code, $start, $end - $start); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/coalesce-assign-side-effect-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/phpunit/src/CompoundTypeValidationTest.php b/phpunit/src/CompoundTypeValidationTest.php new file mode 100644 index 00000000..3bd07008 --- /dev/null +++ b/phpunit/src/CompoundTypeValidationTest.php @@ -0,0 +1,80 @@ +exec('Duplicate type `int` is redundant', 'type_rule_dup_union.php'); + } + + public function testDuplicateClassUnionMemberIsRejected(): void + { + $this->exec('Duplicate type `Foo` is redundant', 'type_rule_dup_class_union.php'); + } + + public function testBoolWithFalseIsRedundant(): void + { + $this->exec('Duplicate type `false` is redundant', 'type_rule_bool_false.php'); + } + + public function testTrueWithFalseMustUseBool(): void + { + $this->exec('Type contains both `true` and `false`, `bool` must be used instead', 'type_rule_true_false.php'); + } + + public function testMixedCannotBeUnionMember(): void + { + $this->exec('Type `mixed` can only be used as a standalone type', 'type_rule_mixed_union.php'); + } + + public function testMixedCannotBeNullable(): void + { + $this->exec('Type `mixed` cannot be marked as nullable since mixed already includes null', 'type_rule_nullable_mixed.php'); + } + + public function testVoidCannotBeUnionMember(): void + { + $this->exec('Type `void` can only be used as a standalone type', 'type_rule_void_union.php'); + } + + public function testIterableExpansionDetectsArrayDuplicate(): void + { + $this->exec('Duplicate type `array` is redundant', 'type_rule_iterable_array.php'); + } + + public function testScalarCannotJoinIntersection(): void + { + $this->exec('Type `int` cannot be part of an intersection type', 'type_rule_intersect_scalar.php'); + } + + public function testDuplicateIntersectionMemberIsRejected(): void + { + $this->exec('Duplicate type `Ix` is redundant', 'type_rule_intersect_dup.php'); + } + + public function testStaticReturnRequiresClassScope(): void + { + $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_return_global.php'); + } + + public function testSelfReturnRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_return_global.php'); + } + + public function testDuplicateImplementsIsRejected(): void + { + $this->exec('Class `C` cannot implement previously implemented interface `Ia`', 'type_rule_implements_dup.php'); + } + + public function testWellFormedCompoundTypesStillCompile(): void + { + $this->compile('type_rule_valid.php'); + } +} diff --git a/phpunit/src/ConstantMetadataLiteralTest.php b/phpunit/src/ConstantMetadataLiteralTest.php new file mode 100644 index 00000000..f7eacdaa --- /dev/null +++ b/phpunit/src/ConstantMetadataLiteralTest.php @@ -0,0 +1,41 @@ +addFiles([$testFile]); + $compiler->prepareFile($testFile); + $compiler->convertFile($testFile); + $arginfoHeader = $compiler->getArgInfoHeaderFile($testFile); + $arginfo = file_get_contents($arginfoHeader); + } finally { + if ($previous !== false) { + ini_set('precision', $previous); + } + } + + self::assertIsString($arginfo); + self::assertStringContainsString('ZVAL_LONG(&const_MIN_value, ZEND_LONG_MIN);', $arginfo); + self::assertStringContainsString('ZVAL_LONG(&const_MAX_value, 9223372036854775807);', $arginfo); + self::assertStringContainsString('ZVAL_LONG(&property_floor_default_value, ZEND_LONG_MIN);', $arginfo); + self::assertStringContainsString('ZVAL_DOUBLE(&const_NEGZ_value, -0.0);', $arginfo); + self::assertStringContainsString('ZVAL_DOUBLE(&const_PI_value, 3.1415926535897931);', $arginfo); + self::assertStringNotContainsString('-9223372036854775808', $arginfo); + } +} diff --git a/phpunit/src/ConstantOverrideCovarianceTest.php b/phpunit/src/ConstantOverrideCovarianceTest.php new file mode 100644 index 00000000..52a036db --- /dev/null +++ b/phpunit/src/ConstantOverrideCovarianceTest.php @@ -0,0 +1,32 @@ + int, mixed -> string, ?int -> int) but never + * widen it or move to an unrelated type. + */ +class ConstantOverrideCovarianceTest extends BaseTest +{ + public function testNarrowingDeclaredTypeCompiles(): void + { + $this->compile('const_override_covariant.php'); + } + + public function testWideningDeclaredTypeIsRejected(): void + { + $this->exec( + 'Declaration of `B::X` must be compatible with `A::X`', + 'const_override_widened.php', + ); + } + + public function testUnrelatedDeclaredTypeIsRejected(): void + { + $this->exec( + 'Declaration of `B::X` must be compatible with `A::X`', + 'const_override_unrelated.php', + ); + } +} diff --git a/phpunit/src/ConstructorOverrideTest.php b/phpunit/src/ConstructorOverrideTest.php new file mode 100644 index 00000000..1ce22883 --- /dev/null +++ b/phpunit/src/ConstructorOverrideTest.php @@ -0,0 +1,41 @@ +compile('ctor_override_valid.php'); + } + + public function testFinalParentConstructorCannotBeOverridden(): void + { + $this->exec( + 'Cannot override final method `A::__construct()`', + 'ctor_override_final.php', + ); + } + + public function testFinalPrivateParentConstructorCannotBeOverridden(): void + { + $this->exec( + 'Cannot override final method `A::__construct()`', + 'ctor_override_final_private.php', + ); + } + + public function testAbstractParentConstructorSignatureIsEnforced(): void + { + $this->exec( + 'Declaration of `B::__construct()` must be compatible with `A::__construct()`', + 'ctor_override_abstract_incompatible.php', + ); + } +} diff --git a/phpunit/src/DecimalLiteralClassificationTest.php b/phpunit/src/DecimalLiteralClassificationTest.php new file mode 100644 index 00000000..ee7a38ce --- /dev/null +++ b/phpunit/src/DecimalLiteralClassificationTest.php @@ -0,0 +1,58 @@ +compileFixture(); + + // Exactly one literal (the 21-digit pi) is promoted... + self::assertSame(1, substr_count($code, 'php::toDecimal(')); + // ...and the borderline literals stay native floats, so every + // is_float() probe statically folds to true. + self::assertGreaterThanOrEqual(3, substr_count($code, 'php::toBool(true)')); + } + + public function testHexLiteralFoldsToExactDouble(): void + { + $code = $this->compileFixture(); + + self::assertStringContainsString('2.0988295480315429e+19', $code); + self::assertStringNotContainsString('0x123456789E1234567', $code); + } + + public function testDecimalLiteralDemotesAgainstFloatTypedExpression(): void + { + $code = $this->compileFixture(); + + // The comparison compiles (no "Cannot convert float expression to + // Decimal" fatal) and compares doubles like Zend. + self::assertStringContainsString('php::equals(f, 3.1415926535897931)', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/decimal-literal-classification.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/phpunit/src/EnumCaseClassConstantTest.php b/phpunit/src/EnumCaseClassConstantTest.php new file mode 100644 index 00000000..8915ce9b --- /dev/null +++ b/phpunit/src/EnumCaseClassConstantTest.php @@ -0,0 +1,74 @@ +compileFixture(); + + // Request init binds the real case object, following constant chains + // and interface constants. + self::assertStringContainsString( + 'php::updateConstant("CaseConstHolder", "CB", php::getEnumCase(php::getClassEntrySafe("CaseConstEnum"), "B"));', + $extension, + ); + self::assertStringContainsString( + 'php::updateConstant("CaseConstHolder", "CHAIN", php::getEnumCase(php::getClassEntrySafe("CaseConstEnum"), "B"));', + $extension, + ); + self::assertStringContainsString( + 'php::updateConstant("CaseConstInterface", "IC", php::getEnumCase(php::getClassEntrySafe("CaseConstEnum"), "B"));', + $extension, + ); + self::assertStringContainsString( + 'php::updateConstant("CaseConstHolder", "IC", php::getEnumCase(php::getClassEntrySafe("CaseConstEnum"), "B"));', + $extension, + ); + // Request shutdown must clear the request-bound object from the + // persistent class entry. + self::assertStringContainsString( + 'php::updateConstant("CaseConstHolder", "CB", php::null);', + $extension, + ); + } + + public function testExpressionValuedBackedCaseEvaluates(): void + { + [$arginfo] = $this->compileFixture(); + + // `case A = 1 + 1;` must register with its evaluated backing value, + // and the placeholder for CB must be the backing value of B, not the + // case-name string. + self::assertStringContainsString('ZVAL_LONG(&enum_case_A_value, 2);', $arginfo); + self::assertStringContainsString('ZVAL_LONG(&const_CB_value, 4);', $arginfo); + self::assertStringNotContainsString('"CB", sizeof("CB") - 1, 1);', $arginfo); + } + + /** @return array{string, string} [arginfo, extension] */ + private function compileFixture(): array + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/enum-case-class-constant.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $compiler->convertFile($source); + $arginfo = file_get_contents($compiler->getArgInfoHeaderFile($source)); + $extension = file_get_contents($compiler->genExtension()); + + self::assertIsString($arginfo); + self::assertIsString($extension); + return [$arginfo, $extension]; + } +} diff --git a/phpunit/src/EnumDeclarationRulesTest.php b/phpunit/src/EnumDeclarationRulesTest.php new file mode 100644 index 00000000..78e8e8f8 --- /dev/null +++ b/phpunit/src/EnumDeclarationRulesTest.php @@ -0,0 +1,82 @@ +exec('Enum `Suit` cannot include properties', 'enum_rule_property.php'); + } + + public function testEnumCannotIncludeStaticProperties(): void + { + $this->exec('Enum `Suit` cannot include properties', 'enum_rule_static_property.php'); + } + + public function testEnumCannotIncludeConstructor(): void + { + $this->exec('Enum `Suit` cannot include magic method `__construct`', 'enum_rule_magic_construct.php'); + } + + public function testEnumCannotIncludeToString(): void + { + $this->exec('Enum `Suit` cannot include magic method `__toString`', 'enum_rule_magic_tostring.php'); + } + + public function testNonBackedCaseMustNotHaveValue(): void + { + $this->exec('Case `Hearts` of non-backed enum `Suit` must not have a value', 'enum_rule_case_value_nonbacked.php'); + } + + public function testBackedCaseMustHaveValue(): void + { + $this->exec('Case `Hearts` of backed enum `Suit` must have a value', 'enum_rule_case_missing_value.php'); + } + + public function testDuplicateCaseIsRejected(): void + { + $this->exec('Cannot redefine class constant `Suit::Hearts`', 'enum_rule_duplicate_case.php'); + } + + public function testCaseClashingWithConstantIsRejected(): void + { + $this->exec('Cannot redefine class constant `Suit::Hearts`', 'enum_rule_case_const_clash.php'); + } + + public function testBackingTypeMustBeIntOrString(): void + { + $this->exec('Enum backing type must be `int` or `string`, `float` given', 'enum_rule_backing_type.php'); + } + + public function testExplicitUnitEnumImplementsIsRejected(): void + { + $this->exec('Enum `Suit` cannot implement previously implemented interface `UnitEnum`', 'enum_rule_implements_unitenum.php'); + } + + public function testNonBackedEnumCannotImplementBackedEnum(): void + { + $this->exec('Non-backed enum `Suit` cannot implement interface `BackedEnum`', 'enum_rule_implements_backedenum_nonbacked.php'); + } + + public function testEnumCannotIncludeAbstractMethod(): void + { + $this->exec('Enum `Suit` cannot include abstract method `f()`', 'enum_rule_abstract_method.php'); + } + + public function testClassCannotExtendEnum(): void + { + // Enum ClassDef flags carry Modifiers::FINAL, so the regular + // final-class inheritance check rejects the extension. + $this->exec('Class `Deck` cannot extend final class `Suit`', 'enum_rule_extends_enum.php'); + } + + public function testWellFormedEnumStillCompiles(): void + { + $this->compile('enum_rule_valid.php'); + } +} diff --git a/phpunit/src/EvalOrderSideEffectsCodegenTest.php b/phpunit/src/EvalOrderSideEffectsCodegenTest.php new file mode 100644 index 00000000..206c4002 --- /dev/null +++ b/phpunit/src/EvalOrderSideEffectsCodegenTest.php @@ -0,0 +1,88 @@ +compileFixture(); + $body = $this->extractFunctionBody($code, 'php_callargorder()'); + + self::assertMatchesRegularExpression( + '/(tmp_var_\d+) = j;\s*\n\s*(tmp_var_\d+) = j = 5L{1,2};/', + $body, + 'the old value of $j must be captured before $j = 5 executes', + ); + self::assertDoesNotMatchRegularExpression( + '/php_pair\(php::toIntArgExact\(j,/', + $body, + '$j must not be read directly after the hoisted assignment', + ); + } + + public function testConcatOperandReadIsSnapshottedBeforeLaterAssignment(): void + { + $code = $this->compileFixture(); + $body = $this->extractFunctionBody($code, 'php_concatorder()'); + + self::assertMatchesRegularExpression( + '/(tmp_var_\d+) = m;\s*\n\s*(tmp_var_\d+) = m = 9L{1,2};/', + $body, + 'the old value of $m must be captured before $m = 9 executes', + ); + self::assertDoesNotMatchRegularExpression( + '/php::concat\(\{php::toString\(m\)/', + $body, + '$m must not be read directly after the hoisted assignment', + ); + } + + public function testPlainArithmeticKeepsZendCvReadSemantics(): void + { + $code = $this->compileFixture(); + $body = $this->extractFunctionBody($code, 'php_plainarithmeticunchanged()'); + + // Zend reads the CV when the ADD executes, i.e. after the nested + // assignment; the direct read of k matches that and must stay. + self::assertMatchesRegularExpression( + '/(tmp_var_\d+) = k = 5L{1,2};\s*\n[^\n]*\(\(k\) \+ \(\1\)\)/', + $body, + ); + self::assertStringNotContainsString('= k;', $body); + } + + private function extractFunctionBody(string $code, string $marker): string + { + $start = strpos($code, $marker); + self::assertIsInt($start, "missing function: {$marker}"); + $end = strpos($code, "\n}", $start); + self::assertIsInt($end); + return substr($code, $start, $end - $start); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/eval-order-side-effects.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/phpunit/src/InheritanceErrorTest.php b/phpunit/src/InheritanceErrorTest.php index 323856be..d1d56258 100644 --- a/phpunit/src/InheritanceErrorTest.php +++ b/phpunit/src/InheritanceErrorTest.php @@ -113,9 +113,11 @@ public function testByRefMismatch() $this->exec('must be compatible', 'inheritance_error_byref.php'); } - public function testVariadicMismatch() + public function testTrailingVariadicMayAbsorbParentParameter() { - $this->exec('must be compatible', 'inheritance_error_variadic.php'); + // Zend accepts a trailing child variadic standing in for the remaining + // parent parameter positions (zend_do_perform_implementation_check). + $this->assertCompiles('inheritance_error_variadic.php'); } public function testMethodVisibilityMismatch() diff --git a/phpunit/src/InterfaceConstantTest.php b/phpunit/src/InterfaceConstantTest.php new file mode 100644 index 00000000..c96a17be --- /dev/null +++ b/phpunit/src/InterfaceConstantTest.php @@ -0,0 +1,74 @@ +compile('interface_const_valid.php'); + } + + public function testTypedInterfaceConstantMustBeCovariant(): void + { + $this->exec( + 'Declaration of `C::X` must be compatible with `I::X`', + 'interface_const_type_mismatch.php', + ); + } + + public function testFinalInterfaceConstantCannotBeOverridden(): void + { + $this->exec( + '`C::X` cannot override final constant `I::X`', + 'interface_const_final_override.php', + ); + } + + public function testFinalInterfaceConstantBindsTransitiveSubclasses(): void + { + $this->exec( + '`C::X` cannot override final constant `I::X`', + 'interface_const_final_via_parent.php', + ); + } + + public function testSameConstantFromTwoInterfacesIsAmbiguous(): void + { + $this->exec( + 'Class `C` inherits both `I1::X` and `I2::X`, which is ambiguous', + 'interface_const_ambiguous.php', + ); + } + + public function testInterfaceConstantOverrideMustStayPublic(): void + { + $this->exec( + 'Access level to `C::X` must be public (as in interface `I`)', + 'interface_const_visibility.php', + ); + } + + public function testInterfaceExtendingInterfaceChecksConstantTypes(): void + { + $this->exec( + 'Declaration of `J::X` must be compatible with `I::X`', + 'interface_extends_const_incompatible.php', + ); + } + + public function testEnumCannotOverrideFinalInterfaceConstant(): void + { + $this->exec( + '`E::X` cannot override final constant `I::X`', + 'enum_interface_const_final.php', + ); + } +} diff --git a/phpunit/src/InterfaceDeclarationRulesTest.php b/phpunit/src/InterfaceDeclarationRulesTest.php new file mode 100644 index 00000000..d2fb7bbf --- /dev/null +++ b/phpunit/src/InterfaceDeclarationRulesTest.php @@ -0,0 +1,49 @@ +exec('Interface function `Runner::run()` cannot contain body', 'interface_rule_body.php'); + } + + public function testInterfaceMethodMustNotBeFinal(): void + { + $this->exec('Interface method `Runner::run()` must not be final', 'interface_rule_final.php'); + } + + public function testInterfaceMethodMustBePublic(): void + { + $this->exec('Access type for interface method `Runner::run()` must be public', 'interface_rule_private.php'); + } + + public function testInterfaceMethodMustNotBeAbstract(): void + { + $this->exec('Interface method `Runner::run()` must not be abstract', 'interface_rule_abstract.php'); + } + + public function testInterfaceConstantMustBePublic(): void + { + $this->exec('Access type for interface constant `Runner::SPEED` must be public', 'interface_rule_const_private.php'); + } + + public function testInterfaceCannotExtendClass(): void + { + $this->exec('`Runner` cannot implement `Base` - it is not an interface', 'interface_rule_extends_class.php'); + } + + public function testInterfaceCannotExtendSameInterfaceTwice(): void + { + $this->exec('Interface `Runner` cannot implement previously implemented interface `A`', 'interface_rule_extends_dup.php'); + } + + public function testInterfacePropertyCannotBeExplicitlyAbstract(): void + { + $this->exec('Property in interface cannot be explicitly abstract', 'interface_rule_prop_abstract.php'); + } +} diff --git a/phpunit/src/InterfaceMethodCollisionTest.php b/phpunit/src/InterfaceMethodCollisionTest.php new file mode 100644 index 00000000..14a3d9d3 --- /dev/null +++ b/phpunit/src/InterfaceMethodCollisionTest.php @@ -0,0 +1,31 @@ +exec( + 'Declaration of `I1::f()` must be compatible with `I2::f()`', + 'interface_multi_extends_incompatible.php' + ); + } + + public function testUnimplementedCollisionOnClassIsRejected(): void + { + $this->exec( + 'Declaration of `I1::f()` must be compatible with `I2::f()`', + 'interface_collision_unimplemented.php' + ); + } + + public function testCompatibleAndSatisfiedCollisionsAreAccepted(): void + { + $this->compile('interface_collision_valid.php'); + } +} diff --git a/phpunit/src/InternalClassOverrideTest.php b/phpunit/src/InternalClassOverrideTest.php new file mode 100644 index 00000000..abc55535 --- /dev/null +++ b/phpunit/src/InternalClassOverrideTest.php @@ -0,0 +1,49 @@ +compile('internal_override_valid.php'); + } + + public function testParameterCannotBeNarrowed(): void + { + $this->exec( + 'Declaration of `C::offsetGet()` must be compatible with `ArrayObject::offsetGet()`', + 'internal_override_param_narrowed.php', + ); + } + + public function testStaticnessMustMatch(): void + { + $this->exec( + 'Declaration of `C::count()` must be compatible with `ArrayObject::count()`', + 'internal_override_static_mismatch.php', + ); + } + + public function testVisibilityCannotBeNarrowed(): void + { + $this->exec( + 'Declaration of `C::count()` must be compatible with `ArrayObject::count()`', + 'internal_override_visibility_narrowed.php', + ); + } + + public function testRealReturnTypeMustBeCovariant(): void + { + $this->exec( + 'Declaration of `C::getMicrosecond()` must be compatible with `DateTime::getMicrosecond()`', + 'internal_override_real_return_mismatch.php', + ); + } +} diff --git a/phpunit/src/MethodOverrideByRefReturnTest.php b/phpunit/src/MethodOverrideByRefReturnTest.php new file mode 100644 index 00000000..688260b5 --- /dev/null +++ b/phpunit/src/MethodOverrideByRefReturnTest.php @@ -0,0 +1,24 @@ +compile('override_byref_return_added.php'); + } + + public function testOverrideCannotDropByRefReturn(): void + { + $this->exec( + 'Declaration of `B::f()` must be compatible with `A::f()`', + 'override_byref_return_dropped.php', + ); + } +} diff --git a/phpunit/src/MethodOverrideVariadicTest.php b/phpunit/src/MethodOverrideVariadicTest.php new file mode 100644 index 00000000..2773bfd4 --- /dev/null +++ b/phpunit/src/MethodOverrideVariadicTest.php @@ -0,0 +1,48 @@ +compile('override_variadic_absorbs_params.php'); + } + + public function testChildVariadicTypeMustCoverEveryAbsorbedPosition(): void + { + $this->exec( + 'Declaration of `B::f()` must be compatible with `A::f()`', + 'override_variadic_bad_type.php', + ); + } + + public function testChildVariadicMustMatchByRefOfAbsorbedPosition(): void + { + $this->exec( + 'Declaration of `B::f()` must be compatible with `A::f()`', + 'override_variadic_byref_mismatch.php', + ); + } + + public function testVariadicParentRequiresVariadicChild(): void + { + $this->exec( + 'Declaration of `B::f()` must be compatible with `A::f()`', + 'override_parent_variadic_child_not.php', + ); + } + + public function testExtraChildParametersCheckedAgainstParentVariadic(): void + { + $this->compile('override_parent_variadic_child_extra.php'); + } +} diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index f82ee73b..3dd1219a 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -44,7 +44,7 @@ public function testStaticStaticPropertyUsesDynamicCalledClassPath(): void $this->assertStringContainsString('= php::toInt(value);', $code); } - public function testNativeIntPropertyAssignOpUsesNativeReference(): void + public function testNativeIntPropertyAssignOpFollowsPhpSemantics(): void { try { $outputFile = $this->compileNativeProperty('native-property-assign-op-int.php'); @@ -52,12 +52,21 @@ public function testNativeIntPropertyAssignOpUsesNativeReference(): void $this->fail($e->getMessage()); } + // `+=` on an int property is lowered to the plain assignment + // `$p = $p + $x` through the PHP-semantics Variant operators: the raw + // scalar-reference compound (`typephp_static_int_ref(...) += ...`) + // has undefined signed overflow where PHP promotes to float and the + // typed-property store raises the TypeError. $code = file_get_contents($outputFile); - $this->assertStringContainsString('typephp_static_int_ref(this_.attr(', $code); - $this->assertStringContainsString('typephp_static_int_ref(box.attr(', $code); - $this->assertSame(2, substr_count($code, 'typephp_static_int_ref(')); - $this->assertStringNotContainsString('this_.attr(get_persistent_prop(0, get_str(0), 0, get_str(1)), true) +=', $code); - $this->assertStringNotContainsString('box.attr(get_persistent_prop(0, get_str(0), 0, get_str(1)), true) +=', $code); + $this->assertStringNotContainsString('typephp_static_int_ref(', $code); + $this->assertMatchesRegularExpression( + '/this_\.attr\([^;]*php::AttrMode::Update\) = \(\(php::Var\(tmp_var_\d+\)\) \+ \(php::Var\(2L{1,2}\)\)\);/', + $code, + ); + $this->assertMatchesRegularExpression( + '/box\.attr\([^;]*php::AttrMode::Update\) = \(\(php::Var\(tmp_var_\d+\)\) \+ \(php::Var\(2L{1,2}\)\)\);/', + $code, + ); } public function testReadonlyPropertiesDoNotUseNativeScalarReferences(): void diff --git a/phpunit/src/OperatorTest.php b/phpunit/src/OperatorTest.php index 92e4823a..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 @@ -107,7 +134,8 @@ public function testFloatLiteralSpecialValuesAndWholeNumbers(): void $this->assertStringContainsString('1.0', $cpp); $this->assertStringContainsString('0.0', $cpp); $this->assertStringContainsString('std::numeric_limits::infinity()', $cpp); - $this->assertStringContainsString('-std::numeric_limits::infinity()', $cpp); + // Unary minus always parenthesizes its operand (see parseUnaryMinus). + $this->assertStringContainsString('-(std::numeric_limits::infinity())', $cpp); $this->assertStringContainsString('std::numeric_limits::quiet_NaN()', $cpp); $this->assertStringContainsString('2.7182818284590451', $cpp); $this->assertStringNotContainsString('2.718281828459)', $cpp); diff --git a/phpunit/src/PhpIntMaxFoldTest.php b/phpunit/src/PhpIntMaxFoldTest.php new file mode 100644 index 00000000..3229c4b1 --- /dev/null +++ b/phpunit/src/PhpIntMaxFoldTest.php @@ -0,0 +1,50 @@ +compileFixture('php-int-max-fold-namespace.php'); + + // The unqualified fetch reads the namespaced constant at runtime. + self::assertStringContainsString('_const_var_FoldNs__PHP_INT_MAX', $code); + // The fully qualified fetch still folds to the overflowed float. + self::assertStringContainsString('9.2233720368547758e+18', $code); + } + + public function testLowercaseNameIsARuntimeConstantLookup(): void + { + $code = $this->compileFixture('php-int-max-fold-global.php'); + + // php_int_max is undefined in PHP; it must stay a runtime lookup + // that raises the undefined-constant Error, never fold. + self::assertStringContainsString('php::constant(', $code); + // The exact-case global fetch keeps folding. + self::assertStringContainsString('9.2233720368547758e+18', $code); + } + + private function compileFixture(string $file): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/' . $file; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + return $code; + } +} diff --git a/phpunit/src/PrivateMethodRedeclareTest.php b/phpunit/src/PrivateMethodRedeclareTest.php new file mode 100644 index 00000000..6e2a4e81 --- /dev/null +++ b/phpunit/src/PrivateMethodRedeclareTest.php @@ -0,0 +1,27 @@ +compile('private_redeclare_valid.php'); + } + + public function testFinalPrivateConstructorIsStillProtectedFromOverride(): void + { + $this->exec( + 'Cannot override final method `A::__construct()`', + 'ctor_override_final_private.php', + ); + } +} diff --git a/phpunit/src/PromotionAndPropertyTypeTest.php b/phpunit/src/PromotionAndPropertyTypeTest.php new file mode 100644 index 00000000..8f6bad27 --- /dev/null +++ b/phpunit/src/PromotionAndPropertyTypeTest.php @@ -0,0 +1,34 @@ +exec('Cannot declare variadic promoted property', 'promotion_rule_variadic.php'); + } + + public function testCallablePropertyTypeIsRejected(): void + { + $this->exec('Property `Bag::$fn` cannot have type `callable`', 'property_rule_callable.php'); + } + + public function testCallablePromotedPropertyTypeIsRejected(): void + { + $this->exec('Property `Bag::$fn` cannot have type `callable`', 'property_rule_callable_promoted.php'); + } + + public function testCallableUnionPropertyTypeIsRejected(): void + { + $this->exec('Property `Bag::$fn` cannot have type `int|callable`', 'property_rule_callable_union.php'); + } + + public function testCallableClassConstantTypeIsRejected(): void + { + $this->exec('Class constant `Bag::FN` cannot have type `callable`', 'const_rule_callable.php'); + } +} diff --git a/phpunit/src/PropertyHookPlacementTest.php b/phpunit/src/PropertyHookPlacementTest.php new file mode 100644 index 00000000..662d2936 --- /dev/null +++ b/phpunit/src/PropertyHookPlacementTest.php @@ -0,0 +1,50 @@ +exec('Cannot declare hooks for static property', 'hook_rule_static.php'); + } + + public function testHooksOnReadonlyPropertyAreRejected(): void + { + $this->exec('Hooked properties cannot be readonly', 'hook_rule_readonly.php'); + } + + public function testHooksInReadonlyClassAreRejected(): void + { + $this->exec('Hooked properties cannot be readonly', 'hook_rule_readonly_class.php'); + } + + public function testAbstractHookedPropertyRequiresAbstractClass(): void + { + $this->exec('Non-abstract class `Box` contains abstract hooked property `$x`', 'hook_rule_abstract_nonabstract_class.php'); + } + + public function testAbstractPropertyNeedsAtLeastOneAbstractHook(): void + { + $this->exec('Abstract property `Box::$x` must specify at least one abstract hook', 'hook_rule_abstract_all_bodies.php'); + } + + public function testOnlyHookedPropertiesMayBeAbstract(): void + { + $this->exec('Only hooked properties may be declared abstract', 'hook_rule_abstract_no_hooks.php'); + } + + public function testNonAbstractHookMustHaveBody(): void + { + $this->exec('Non-abstract property hook must have a body', 'hook_rule_bodyless.php'); + } + + public function testWellFormedHooksStillCompile(): void + { + $this->compile('hook_rule_valid.php'); + } +} diff --git a/phpunit/src/ReadonlyDeclarationRulesTest.php b/phpunit/src/ReadonlyDeclarationRulesTest.php new file mode 100644 index 00000000..ec851d5a --- /dev/null +++ b/phpunit/src/ReadonlyDeclarationRulesTest.php @@ -0,0 +1,46 @@ +exec('Readonly property `Cfg::$port` cannot have default value', 'readonly_rule_default.php'); + } + + public function testReadonlyPropertyMustHaveType(): void + { + $this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_untyped.php'); + } + + public function testStaticPropertyCannotBeReadonly(): void + { + $this->exec('Static property `Cfg::$port` cannot be readonly', 'readonly_rule_static.php'); + } + + public function testPromotedReadonlyParamMustHaveType(): void + { + $this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_promoted_untyped.php'); + } + + public function testReadonlyClassPropertyMustHaveType(): void + { + $this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_class_untyped.php'); + } + + public function testReadonlyClassCannotDeclareStaticProperty(): void + { + $this->exec('Static property `Cfg::$port` cannot be readonly', 'readonly_rule_class_static.php'); + } + + public function testWellFormedReadonlyDeclarationsStillCompile(): void + { + // Promoted readonly params may keep a parameter default: it belongs + // to the constructor argument, not to the property. + $this->compile('readonly_rule_valid.php'); + } +} diff --git a/phpunit/src/StaticPropertyOverrideTest.php b/phpunit/src/StaticPropertyOverrideTest.php new file mode 100644 index 00000000..adb5d66f --- /dev/null +++ b/phpunit/src/StaticPropertyOverrideTest.php @@ -0,0 +1,32 @@ +compile('property_static_match.php'); + } + + public function testStaticCannotBecomeInstance(): void + { + $this->exec( + 'Cannot redeclare static `A::$x` as non static `B::$x`', + 'property_static_mismatch.php', + ); + } + + public function testInstanceCannotBecomeStatic(): void + { + $this->exec( + 'Cannot redeclare non static `A::$x` as static `B::$x`', + 'property_nonstatic_mismatch.php', + ); + } +} diff --git a/phpunit/src/TraitAdaptationValidationTest.php b/phpunit/src/TraitAdaptationValidationTest.php new file mode 100644 index 00000000..6179ff35 --- /dev/null +++ b/phpunit/src/TraitAdaptationValidationTest.php @@ -0,0 +1,58 @@ +compile('trait_adaptations_valid.php'); + } + + public function testUnqualifiedAliasForMissingMethod(): void + { + $this->exec( + 'An alias (`g`) was defined for method `missing()`, but this method does not exist', + 'trait_alias_missing_method.php', + ); + } + + public function testQualifiedAliasForMissingMethod(): void + { + $this->exec( + 'An alias was defined for `A::missing` but this method does not exist', + 'trait_alias_missing_qualified.php', + ); + } + + public function testAliasReferencingUnusedTrait(): void + { + $this->exec( + "Required Trait `B` wasn't added to `C`", + 'trait_alias_trait_not_used.php', + ); + } + + public function testPrecedenceRuleForMissingMethod(): void + { + $this->exec( + 'A precedence rule was defined for `B::f` but this method does not exist', + 'trait_insteadof_missing_method.php', + ); + } + + public function testPrecedenceRuleReferencingUnusedTrait(): void + { + $this->exec( + "Required Trait `D` wasn't added to `C`", + 'trait_insteadof_trait_not_used.php', + ); + } +} diff --git a/phpunit/src/TraitMemberValueConflictTest.php b/phpunit/src/TraitMemberValueConflictTest.php new file mode 100644 index 00000000..560827c8 --- /dev/null +++ b/phpunit/src/TraitMemberValueConflictTest.php @@ -0,0 +1,32 @@ +compile('trait_member_same_value_spelling.php'); + } + + public function testDifferentConstantValuesConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_value_conflict.php'); + } + + public function testDifferentPropertyDefaultsConflict(): void + { + $this->exec('property `p` already exists', 'trait_prop_value_conflict.php'); + } + + public function testValueComparisonIsIdentityNotEquality(): void + { + $this->exec('constant `x` already exists', 'trait_const_identity_conflict.php'); + } +} diff --git a/phpunit/src/TypedCompoundAssignCodegenTest.php b/phpunit/src/TypedCompoundAssignCodegenTest.php new file mode 100644 index 00000000..c36051e4 --- /dev/null +++ b/phpunit/src/TypedCompoundAssignCodegenTest.php @@ -0,0 +1,69 @@ +compileFixture(); + + self::assertStringContainsString('a = php::toInt(((php::Var(a)) + (php::Var(b))))', $code); + self::assertStringContainsString('a = php::toInt(((php::Var(a)) / (php::Var(b))))', $code); + self::assertStringContainsString('a = php::toInt(php::fn::mod(a, b))', $code); + self::assertStringContainsString('a = php::toInt(((php::Var(a)) << (php::Var(b))))', $code); + self::assertStringNotContainsString('a += ', $code); + self::assertStringNotContainsString('a /= ', $code); + self::assertStringNotContainsString('a %= ', $code); + self::assertStringNotContainsString('a <<= ', $code); + } + + public function testPostIncrementKeepsOldValueThroughNativeTemporary(): void + { + $code = $this->compileFixture(); + + self::assertMatchesRegularExpression( + '/\(tmp_var_\d+ = a, a = php::toInt\(\(\(php::Var\(a\)\) \+ \(php::Var\(1L{1,2}\)\)\)\), tmp_var_\d+\)/', + $code, + ); + self::assertStringNotContainsString('a++', $code); + } + + public function testPreDecrementIsLoweredToPlainAssignment(): void + { + $code = $this->compileFixture(); + + self::assertMatchesRegularExpression('/\(a = php::toInt\(\(\(php::Var\(a\)\) - \(php::Var\(1L{1,2}\)\)\)\)\)/', $code); + self::assertStringNotContainsString('--a', $code); + } + + public function testWellDefinedBitwiseCompoundStaysRaw(): void + { + $code = $this->compileFixture(); + + self::assertStringContainsString('a &= ', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/typed-compound-assign-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/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/phpunit/src/UnaryMinusCodegenTest.php b/phpunit/src/UnaryMinusCodegenTest.php new file mode 100644 index 00000000..e6c5ed7d --- /dev/null +++ b/phpunit/src/UnaryMinusCodegenTest.php @@ -0,0 +1,43 @@ +compileFixture(); + + self::assertStringContainsString('-((php::toBool(a)) ? (b) : (c))', $code); + self::assertStringNotContainsString('-(php::toBool(a)) ?', $code); + } + + public function testNestedUnaryMinusDoesNotPasteIntoPreDecrement(): void + { + $code = $this->compileFixture(); + + self::assertStringNotContainsString('--a', $code); + } + + private function compileFixture(): string + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/unary-minus-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/CompilerBase.php b/src/CompilerBase.php index 29e11025..980aec09 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -160,6 +160,7 @@ class CompilerBase implements PropertyAccessContext protected const string ATTR_STATEMENT_EXPRESSION = 'aotStatementExpression'; protected const string ATTR_MULTI_RETURN_IMPL = 'aotMultiReturnImpl'; protected const string ATTR_SCOPED_CALLBACK = 'aotScopedCallback'; + protected const string ATTR_FORCE_FLOAT_LITERAL = 'aotForceFloatLiteral'; /** * Keyword methods (to* builtins) with mandated return types. @@ -547,6 +548,23 @@ public function setDiagnosticReporter(DiagnosticReporter $reporter): void $this->diagnosticReporter = $reporter; } + /** + * Run $fn with diagnostics raised as exceptions instead of terminating + * the compiler, restoring the previous reporter afterwards. Lets a caller + * attempt an evaluation that is allowed to fail (e.g. a compile-time + * constant lookup that a later phase resolves independently). + */ + protected function withThrowingDiagnostics(callable $fn): mixed + { + $previous = $this->diagnosticReporter; + $this->diagnosticReporter = new ThrowingDiagnosticReporter(); + try { + return $fn(); + } finally { + $this->diagnosticReporter = $previous; + } + } + protected function getDiagnosticReporter(): DiagnosticReporter { if ($this->diagnosticReporter !== null) { @@ -3308,10 +3326,51 @@ protected function parsePreInc(Expr\PreInc $expr): string if ($type === Type::BIGINT || $type === Type::DECIMAL || $type === Type::BIGFLOAT) { $this->fatalError($expr, 'Cannot use ++ on ' . $type . '. Use += 1 instead (Big* types are immutable).'); } + $incDec = $this->parseNativeIntIncDec($expr->var, '+', false, $expr); + if ($incDec !== null) { + return $incDec; + } $result = '++' . $this->parseWritableIdentifier($expr->var); return $result; } + /** + * Lower ++/-- on a native int slot to the PHP-semantics plain assignment. + * + * A raw C++ ++/-- on a zend_long slot has undefined signed overflow at + * PHP_INT_MAX/PHP_INT_MIN, where PHP promotes the result to float. The + * plain assignment `$x = $x + 1` already routes through the encapsulated + * Variant operators and performs the established typed-slot coercion. + * Post-increment keeps its expression value (the old value) through a + * native temporary inside one comma expression, so no side effect is + * hoisted out of its evaluation position. + */ + protected function parseNativeIntIncDec( + Expr $target, + string $op, + bool $returnOldValue, + NodeAbstract $sourceNode, + ): ?string { + if ($this->nativeTypes || !$this->isVarExpr($target)) { + return null; + } + $var = (string) $this->parseIdentifier($target); + if (!$this->hasVar($var) || $this->detectVarType($target) !== Type::INT) { + return null; + } + $attributes = $sourceNode->getAttributes(); + $one = new Node\Scalar\Int_(1, $attributes); + $binary = $op === '+' + ? new Expr\BinaryOp\Plus($target, $one, $attributes) + : new Expr\BinaryOp\Minus($target, $one, $attributes); + $assign = $this->parseAssign(new Expr\Assign($target, $binary, $attributes)); + if (!$returnOldValue) { + return '(' . $assign . ')'; + } + $tmpVar = $this->addTmpVar(Type::INT); + return '(' . $tmpVar . ' = ' . $var . ', ' . $assign . ', ' . $tmpVar . ')'; + } + /** * Resolve a PHP function name to its native (compiled) name by trying * every candidate form: absolute names, qualified names resolved through @@ -3701,6 +3760,10 @@ protected function parsePostOp(Expr\PostDec|Expr\PostInc $expr, string $op): str $opName = $op === '+' ? '++' : '--'; $this->fatalError($expr, "Cannot use {$opName} on {$type}. Use " . ($op === '+' ? '+= 1' : '-= 1') . ' instead (Big* types are immutable).'); } + $incDec = $this->parseNativeIntIncDec($expr->var, $op, true, $expr); + if ($incDec !== null) { + return $incDec; + } return $var . str_repeat($op, 2); } if ($this->isStaticPropertyFetch($expr->var)) { @@ -3747,6 +3810,10 @@ protected function parsePreDec(Expr\PreDec $expr): string if ($type === Type::BIGINT || $type === Type::DECIMAL || $type === Type::BIGFLOAT) { $this->fatalError($expr, 'Cannot use -- on ' . $type . '. Use -= 1 instead (Big* types are immutable).'); } + $incDec = $this->parseNativeIntIncDec($expr->var, '-', false, $expr); + if ($incDec !== null) { + return $incDec; + } $result = '--' . $this->parseWritableIdentifier($expr->var); return $result; } diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index 5d9bb163..b2abfdc7 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -78,14 +78,18 @@ class ClassDef extends ClassLikeDef public array $traitUseConstants = []; /** - * FullMethodName -> alias list - * @var array> + * FullMethodName -> alias list. `group` identifies the source adaptation + * (an unqualified alias is registered under every used trait's key), + * `method` is the aliased method as written, and `trait` the explicit + * trait qualifier or null. + * @var array> */ public array $traitAliases = []; /** - * FullMethodName -> true - * @var array + * FullMethodName of the ignored (overridden) method -> precedence rule + * info for existence validation. Consumers test the key with isset(). + * @var array */ public array $traitIgnored = []; public int $flags; diff --git a/src/Entity/ConstantDef.php b/src/Entity/ConstantDef.php index c4c62cde..f8ed5d23 100644 --- a/src/Entity/ConstantDef.php +++ b/src/Entity/ConstantDef.php @@ -24,6 +24,16 @@ class ConstantDef /** Explicit declared type (e.g. `const int FOO`); null for inferred/untyped constants. */ public ?string $declaredType = null; + /** + * Accepted-types DNF for the explicitly declared type, in the same format + * as ArgInfo::$typeCheck. Empty when the constant is untyped or the + * declared type accepts everything (`mixed`). + */ + public array $typeCheck = []; + + /** Human-readable declared type string for diagnostics ('' when untyped). */ + public string $typeStr = ''; + public function __construct(string $name, int $flags, string $type, string $value) { $this->name = $name; diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index 03c70f44..0f1c836f 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -130,10 +130,37 @@ protected function parseNativeCallArgs( $variadicVar = null; $callableName = $functionDef->displayName ?: $functionDef->getNamespacedName(); + // PHP evaluates arguments left to right. A later argument that hoists + // captured statements while being lowered (an assignment, a call) + // would execute those side effects before an earlier plain-variable + // argument is read: `two($j, $j = 5)` must pass the old value of $j. + // Record the last such argument so every earlier by-value variable + // read can be snapshotted at its own argument position. + $lastHoistingSourceIndex = -1; + foreach ($sourceArgs as $sourceIndex => [, , $arg]) { + if ($arg instanceof Node\Arg && $this->shouldMaterializeOrderedOperand($arg->value)) { + $lastHoistingSourceIndex = $sourceIndex; + } + } + // Evaluate every supplied argument in PHP source order. The resulting // expressions/temporaries may then be rearranged safely for the native // C++ ABI without changing observable call order. - foreach ($sourceArgs as [$argIndex, $variadicName, $arg]) { + foreach ($sourceArgs as $sourceIndex => [$argIndex, $variadicName, $arg]) { + if ($sourceIndex < $lastHoistingSourceIndex + && $arg instanceof Node\Arg + && !$arg->unpack + && $this->isSnapshotableVariableRead($arg->value) + ) { + $paramInfo = $argIndex === $variadicArgIndex + ? $functionDef->argInfoList[$variadicArgIndex] + : $this->getArgInfo($arg, $nativeFunc, $argIndex); + if ($paramInfo !== null && !$paramInfo->byRef) { + $snapshot = $this->parseOrderedOperand($arg->value, false, true); + $arg = clone $arg; + $arg->value = new Expr\Variable($snapshot, $arg->value->getAttributes()); + } + } if ($argIndex !== $variadicArgIndex) { $argInfo = $this->getArgInfo($arg, $nativeFunc, $argIndex); $resolvedArgs[$argIndex] = $this->getTypeConvertedArg( diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 470490d1..a99bb08c 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -890,6 +890,25 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string return $nativePropertyAssignOp; } + // A compound assignment on a native int/float slot cannot be emitted + // as a raw C++ compound operator: `+=` overflow is UB where PHP + // promotes to float, `/=` truncates zend_long division and misses the + // catchable DivisionByZeroError, `%= 0` and out-of-range shifts are + // UB. Lower it to the equivalent plain assignment `$x = $x op $y`, + // which already routes through the PHP-semantics binary operators and + // performs the established coercion back into the typed slot. + if (!$this->nativeTypes + && $this->isVarExpr($node->var) + && $this->hasVar((string) $this->parseIdentifier($node->var)) + && $this->assignOpNeedsPhpSemantics($this->detectVarType($node->var), $op) + ) { + return $this->parseAssign(new Expr\Assign( + $node->var, + $this->assignOpBinaryNode($node), + $node->getAttributes(), + )); + } + $arrayDimFetch = $this->isArrayDimFetch($node->var); $var = $arrayDimFetch ? '' : $this->parseWritableIdentifier($node->var); $expr = $this->isAssignOpConcat($op) ? '' : (string) $this->parseIdentifier($node->expr); @@ -1109,6 +1128,23 @@ protected function parseNativePropertyAssignOp(Expr\AssignOp $node, string $op): . ' of type ' . $this->getObjectPropertyTypeCheckTypeString($def) ); } + // Int/float typed properties expose a raw scalar reference, so the + // compound C++ operator has the same UB/semantic divergences as a raw + // compound on a native local slot. Lower those operators to the plain + // assignment `$this->p = $this->p op $y`, whose binary expression is + // already routed through the PHP-semantics operators and whose write + // performs the established typed-property store. + if (!$this->nativeTypes + && in_array($def->type, [Type::INT, Type::FLOAT], true) + && $this->assignOpNeedsPhpSemantics($def->type, $op) + ) { + return $this->parseAssign(new Expr\Assign( + $node->var, + $this->assignOpBinaryNode($node), + $node->getAttributes(), + )); + } + if (!$this->canUseNativePropertyAssignOp($def->type, $rightType, $op)) { return null; } @@ -1130,6 +1166,48 @@ protected function parseNativePropertyAssignOp(Expr\AssignOp $node, string $op): return $var . ' ' . $op . ' (' . $this->convertNativePropertyWriteExpr($def->type, $effectiveRightType, $rightExpr) . ')'; } + /** + * Whether `$x op= $y` on a native int/float slot must be lowered to the + * PHP-semantics plain assignment `$x = $x op $y` instead of a raw C++ + * compound operator. Raw int compound arithmetic has undefined signed + * overflow (PHP promotes to float), raw division truncates and misses + * DivisionByZeroError, `%` is UB for a zero divisor and PHP_INT_MIN % -1, + * and shifts are UB for out-of-range counts. Raw float `/` yields + * INF instead of DivisionByZeroError, while float `%` and shifts operate + * on int casts in PHP and do not even compile on a C++ double. + * Bitwise `&= |= ^=` on ints and float `+= -= *=` are identical in C++ + * and PHP and keep the raw compound form. + */ + protected function assignOpNeedsPhpSemantics(string $slotType, string $op): bool + { + if ($slotType === Type::INT) { + return in_array($op, ['+=', '-=', '*=', '/=', '%=', '<<=', '>>='], true); + } + if ($slotType === Type::FLOAT) { + return in_array($op, ['/=', '%=', '<<=', '>>='], true); + } + return false; + } + + /** + * Build the binary-op AST node equivalent to a compound assignment, so + * `$x op= $y` can reuse the plain `$x = $x op $y` lowering. + */ + protected function assignOpBinaryNode(Expr\AssignOp $node): Expr\BinaryOp + { + $attributes = $node->getAttributes(); + return match (true) { + $node instanceof Expr\AssignOp\Plus => new Expr\BinaryOp\Plus($node->var, $node->expr, $attributes), + $node instanceof Expr\AssignOp\Minus => new Expr\BinaryOp\Minus($node->var, $node->expr, $attributes), + $node instanceof Expr\AssignOp\Mul => new Expr\BinaryOp\Mul($node->var, $node->expr, $attributes), + $node instanceof Expr\AssignOp\Div => new Expr\BinaryOp\Div($node->var, $node->expr, $attributes), + $node instanceof Expr\AssignOp\Mod => new Expr\BinaryOp\Mod($node->var, $node->expr, $attributes), + $node instanceof Expr\AssignOp\ShiftLeft => new Expr\BinaryOp\ShiftLeft($node->var, $node->expr, $attributes), + $node instanceof Expr\AssignOp\ShiftRight => new Expr\BinaryOp\ShiftRight($node->var, $node->expr, $attributes), + default => $this->fatalError($node, 'Unsupported compound assignment lowering'), + }; + } + protected function convertNativePropertyWriteExpr(string $propertyType, string $rightType, string $rightExpr): string { if ($propertyType === $rightType) { @@ -1585,9 +1663,21 @@ protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string $code .= $this->getIndent() . '}()'; return $code; } - $this->appendCapturedStmtLinesToContext($rightBefore); - foreach ($rightAfter as $stmt) { - $this->context->afterStmtLines[] = $stmt; + if ($rightBefore !== [] || $rightAfter !== []) { + // PHP evaluates the RHS of ??= only when the target is not set. + // A compound RHS materializes captured statements (call results, + // operand temporaries); appending them to the enclosing statement + // would run its side effects unconditionally. Wrap the not-set + // branch in an immediately-invoked lambda so they execute only + // when the assignment actually happens. + $code = '[&]() {' . PHP_EOL; + $code .= $this->getIndent() . 'if (' . $isset . ') { return ' . $var . '; }' . PHP_EOL; + $code .= $this->formatCapturedStmtLines($rightBefore); + $code .= $this->getIndent() . $var . ' = ' . $right . ';' . PHP_EOL; + $code .= $this->formatCapturedStmtLines($rightAfter); + $code .= $this->getIndent() . 'return ' . $var . ';' . PHP_EOL; + $code .= $this->getIndent() . '}()'; + return $code; } return '(' . $isset . '?' . $var . ':(' . $var . ' = ' . $right . '))'; } diff --git a/src/Parser/BinaryOpTrait.php b/src/Parser/BinaryOpTrait.php index 8ba4e9aa..7d29c13f 100644 --- a/src/Parser/BinaryOpTrait.php +++ b/src/Parser/BinaryOpTrait.php @@ -24,6 +24,8 @@ protected function parseBinaryOp(NodeAbstract $left, NodeAbstract $right, string $this->assertExprCanBeUsedAsValue($left, 'binary operand'); $this->assertExprCanBeUsedAsValue($right, 'binary operand'); + $this->demoteAutoDecimalLiteralAgainstFloat($left, $right); + // Arithmetic logic: convert to a numeric type first when possible $leftExpr = $this->parseOrderedBinaryOperand($left); $rightExpr = $this->parseOrderedBinaryOperand($right); @@ -139,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 === '>>') { @@ -148,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); @@ -170,6 +208,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 . '))'; } @@ -393,10 +449,13 @@ protected function constantNumericValue(NodeAbstract $expr, bool $nativeSemantic return $value === null ? null : -$value; } if ($expr instanceof Node\Expr\ConstFetch) { - $name = strtolower($expr->name->toString()); + // PHP constants are case-sensitive and unqualified names resolve + // through the namespace first, so only a fetch that provably + // names the global constant may fold to its value. + $name = $this->resolveGlobalFoldableConstantName($expr); return match ($name) { - 'php_int_max' => PHP_INT_MAX, - 'php_int_min' => PHP_INT_MIN, + 'PHP_INT_MAX' => PHP_INT_MAX, + 'PHP_INT_MIN' => PHP_INT_MIN, default => null, }; } @@ -441,6 +500,34 @@ protected function constantNumericValue(NodeAbstract $expr, bool $nativeSemantic }; } + /** + * Resolve a constant fetch to the global constant name it provably + * denotes, or null when the fetch may refer to something else. + * + * A `use const` alias resolves to its target. A fully qualified name is + * already global. An unqualified name inside a namespace participates in + * PHP's runtime fallback (Namespace\NAME can be defined before the fetch + * executes), so it never provably names the global constant. A qualified + * relative name resolves inside a namespace/import and is never global. + */ + protected function resolveGlobalFoldableConstantName(Node\Expr\ConstFetch $expr): ?string + { + $name = ltrim($expr->name->toString(), '\\'); + if (isset($this->useConstants[$name])) { + return ltrim($this->useConstants[$name], '\\'); + } + if ($expr->name instanceof Node\Name\FullyQualified) { + return $name; + } + if (!$expr->name->isUnqualified()) { + return null; + } + if ($this->namespace) { + return null; + } + return $name; + } + protected function constantDivisionValue(int|float $left, int|float $right, bool $nativeSemantics): int|float|null { if ($right == 0) { @@ -472,21 +559,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 . ')))'; } @@ -759,8 +850,38 @@ protected function parseFlattenedConcat(NodeAbstract $expr, array $prefixExpress $useTwoOperandOverload = $prefixExpressions === [] && $this->canUseTwoOperandConcatOverload($items); + // Zend lowers the left-associated chain i0.i1.i2... into one CONCAT + // opcode per node and reads a CV operand when its opcode executes: + // i0 and i1 are both read at the first op (after the side effects of + // both), and every later item ik at the k-th op (after the side + // effects of i0..ik, before those of later items). The flattened + // braced list hoists all captured side effects ahead of the whole + // expression, so a plain-variable item that Zend reads before a later + // item's side effects (`$m . ',' . ($m = 9)` must yield "1,9") is + // snapshotted into a temporary at its Zend read position. + $lastHoistingIndex = -1; + foreach ($items as $index => $item) { + if ($this->shouldMaterializeOrderedOperand($item) + || $this->isNativeObjectClass($this->detectClassOfExpr($item)) + ) { + $lastHoistingIndex = $index; + } + } + + // The first item is read together with the second at the first op, + // i.e. after the second item's side effects. Its snapshot is deferred + // until the second item has been lowered. + $deferFirstItemSnapshot = $lastHoistingIndex >= 2 + && isset($items[1]) + && $this->isSnapshotableVariableRead($items[0]) + && !($this->isScalarString($items[1]) && $items[1]->value === ''); + $argList = $prefixExpressions; - foreach ($items as $item) { + foreach ($items as $index => $item) { + if ($deferFirstItemSnapshot && $index === 0) { + continue; + } + // Keep one operand so concat still performs PHP string coercion. // Prefix expressions are operands too (for example, the left-hand // value of `.=`), so an empty RHS literal can be omitted there. @@ -768,20 +889,34 @@ protected function parseFlattenedConcat(NodeAbstract $expr, array $prefixExpress continue; } + $entryPosition = count($argList); $itemClass = $this->detectClassOfExpr($item); if ($this->isNativeObjectClass($itemClass)) { $toString = new Expr\MethodCall($item, new Node\Identifier('toString')); $argList[] = $this->parseOrderedOperand($toString, false); - continue; + } else { + $type = $this->detectTypeOfExpr($item); + // C++17 evaluates the braced-list elements in order. The + // temporary is still required because lowering a later operand + // may append captured beforeStmtLines ahead of the entire + // concat expression; without it, those statements could + // overtake an earlier Call. + $snapshotEarlierRead = $index >= 1 + && $index < $lastHoistingIndex + && $this->isSnapshotableVariableRead($item); + $parsed = $this->parseOrderedOperand($item, false, $snapshotEarlierRead); + $argList[] = $this->prepareConcatOperand($parsed, $type); } - $type = $this->detectTypeOfExpr($item); - // C++17 evaluates the braced-list elements in order. The temporary - // is still required because lowering a later operand may append - // captured beforeStmtLines ahead of the entire concat expression; - // without it, those statements could overtake an earlier Call. - $parsed = $this->parseOrderedOperand($item, false); - $argList[] = $this->prepareConcatOperand($parsed, $type); + if ($deferFirstItemSnapshot && $index === 1) { + // Snapshot the first item now, after the second item's side + // effects, and keep its leading position in the operand list. + $firstType = $this->detectTypeOfExpr($items[0]); + $firstParsed = $this->parseOrderedOperand($items[0], false, true); + array_splice($argList, $entryPosition, 0, [ + $this->prepareConcatOperand($firstParsed, $firstType), + ]); + } } if ($useTwoOperandOverload && count($argList) === 2) { @@ -791,6 +926,24 @@ protected function parseFlattenedConcat(NodeAbstract $expr, array $prefixExpress return Symbol::concat() . '({' . implode(', ', $argList) . '})'; } + /** + * Whether an operand is a plain local variable read whose value can be + * snapshotted into a temporary to preserve left-to-right evaluation when + * a later operand hoists side-effecting statements. `$this` cannot be + * reassigned and $GLOBALS has dedicated lowering; both are left alone. + */ + protected function isSnapshotableVariableRead(NodeAbstract $expr): bool + { + if (!$this->isVarExpr($expr) || !is_string($expr->name)) { + return false; + } + if ($expr->name === 'this' || $expr->name === 'GLOBALS') { + return false; + } + $var = (string) $this->parseIdentifier($expr); + return $this->hasVar($var) && !$this->isStdContainer($var); + } + protected function canUseTwoOperandConcatOverload(array $items): bool { if (count($items) !== 2) { @@ -951,6 +1104,7 @@ protected function parseBinaryOpIdentical(Expr\BinaryOp $expr): string if ($pythonOperator !== null) { return $pythonOperator; } + $this->demoteAutoDecimalLiteralAgainstFloat($expr->left, $expr->right); $left = $this->parseCompareExpr($expr->left); $right = $this->parseCompareExpr($expr->right); $leftIsNative = $this->isNativeObjectClass($this->detectClassOfExpr($expr->left)); @@ -1113,8 +1267,36 @@ protected function parseBinaryOpSpaceship(Expr\BinaryOp\Spaceship $expr): string ?? 'php::compare(' . $this->parseOrderedOperand($expr->left, false) . ', ' . $this->parseOrderedOperand($expr->right, false) . ')'; } + /** + * When an auto-Decimal-classified float literal meets a float-typed + * expression in a binary operation, demote the literal to its exact + * double. PHP evaluates every float literal as a double, so rejecting + * the mix ("Cannot convert float expression to Decimal") refuses valid + * PHP — e.g. `0.1 + 0.2 == 0.30000000000000004` from a var_export round + * trip — and keeping the Decimal would change comparison semantics. + */ + protected function demoteAutoDecimalLiteralAgainstFloat(NodeAbstract $left, NodeAbstract $right): void + { + if ($this->decimalTypes) { + return; + } + $leftType = $this->detectTypeOfExpr($left); + $rightType = $this->detectTypeOfExpr($right); + foreach ([[$left, $leftType, $rightType], [$right, $rightType, $leftType]] as [$node, $type, $otherType]) { + if ($type === Type::DECIMAL + && $otherType === Type::FLOAT + && $node instanceof Node\Scalar\Float_ + && $this->isDecimalLiteral($node) + ) { + $node->setAttribute(self::ATTR_FORCE_FLOAT_LITERAL, true); + } + } + } + protected function genBigNumericCmp(Expr\BinaryOp $expr, string $suffix = ''): ?string { + $this->demoteAutoDecimalLiteralAgainstFloat($expr->left, $expr->right); + $leftType = $this->detectTypeOfExpr($expr->left); $rightType = $this->detectTypeOfExpr($expr->right); @@ -1184,7 +1366,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/src/Parser/TypeDetectionTrait.php b/src/Parser/TypeDetectionTrait.php index 0e3762ca..2fb36078 100644 --- a/src/Parser/TypeDetectionTrait.php +++ b/src/Parser/TypeDetectionTrait.php @@ -47,18 +47,103 @@ protected function isBigIntLiteral(Node\Scalar $expr): bool protected function isDecimalLiteral(Node\Scalar $expr): bool { + if ($expr->getAttribute(self::ATTR_FORCE_FLOAT_LITERAL, false)) { + return false; + } $rawValue = $expr->getAttribute('rawValue'); if ($rawValue === null) { return false; } $clean = $this->stripNumericUnderscores($rawValue); + // Hex/octal/binary notation folds to its exact numeric value in Zend + // (an overflowing hex literal becomes the exact double); only decimal + // notation participates in the Decimal promotion. A hex literal whose + // digits contain E would otherwise match the exponent test below. + if (preg_match('/^[+-]?0[xXbBoO]/', $clean)) { + return false; + } // Must have a decimal point or exponent (not a pure integer) if (!preg_match('/[\.eE]/', $clean)) { return false; } - // Count significant digits (exclude ., e, E, +, -) - $digits = preg_replace('/[^0-9]/', '', $clean); - return strlen(ltrim($digits, '0')) >= 16; + // Documented rule: 16 or more significant digits promote to Decimal. + // Exponent digits carry no precision, and neither do leading or + // trailing mantissa zeros (999999999999999.0 has 15). + if ($this->countSignificantMantissaDigits($clean) < 16) { + return false; + } + // The promotion exists for literals that exceed double precision. A + // literal the double reproduces exactly — every var_export/serialize + // round-trip, PHP_FLOAT_EPSILON, ... — has lost nothing and stays a + // native float. + return !$this->floatLiteralRoundTripsExactly($clean); + } + + /** + * Count the significant decimal digits of a numeric literal's mantissa: + * sign and exponent are ignored, leading zeros carry no precision, and + * trailing mantissa zeros do not require more precision than the double. + */ + protected function countSignificantMantissaDigits(string $literal): int + { + $mantissa = ltrim($literal, '+-'); + $mantissa = preg_split('/[eE]/', $mantissa)[0]; + $digits = str_replace('.', '', $mantissa); + $digits = trim($digits, '0'); + return strlen($digits); + } + + /** + * Whether the decimal literal denotes exactly the value of its double + * representation (i.e. converting to double loses nothing). + */ + protected function floatLiteralRoundTripsExactly(string $literal): bool + { + $value = (float) $literal; + if (!is_finite($value)) { + // The double overflowed; Decimal preserves the written value. + return false; + } + $shortest = $this->shortestFloatRepr($value); + return $this->normalizeDecimalLiteral($literal) === $this->normalizeDecimalLiteral($shortest); + } + + /** + * Shortest decimal representation that parses back to exactly $value, + * independent of the precision/serialize_precision ini settings. + */ + protected function shortestFloatRepr(float $value): string + { + for ($precision = 0; $precision <= 17; $precision++) { + $candidate = sprintf('%.' . $precision . 'e', $value); + if ((float) $candidate === $value) { + return $candidate; + } + } + return sprintf('%.17e', $value); + } + + /** + * Normalize a decimal literal to [sign, significant digits, exponent] so + * two spellings of the same real number compare equal. + * + * @return array{string, string, int}|null + */ + protected function normalizeDecimalLiteral(string $literal): ?array + { + if (!preg_match('/^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/', trim($literal), $m)) { + return null; + } + $sign = $m[1] === '-' ? '-' : '+'; + $fraction = $m[3] ?? ''; + $exponent = (int) ($m[4] ?? 0) - strlen($fraction); + $digits = ltrim($m[2] . $fraction, '0'); + $trimmed = rtrim($digits, '0'); + $exponent += strlen($digits) - strlen($trimmed); + if ($trimmed === '') { + return ['+', '', 0]; + } + return [$sign, $trimmed, $exponent]; } protected function isFloatStr(string $str): bool diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index c4a92e6a..9fd45e5a 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -125,15 +125,19 @@ protected function parseUnaryMinus(Expr\UnaryMinus $expr): string } $code = $this->parseExprAsValue($expr->expr); - // An operand that already starts with `-` (a nested unary minus, a - // negative literal) would paste into the C++ pre-decrement token: - // `- -$a` -> `--a`. Parenthesize exactly then, so plain literals - // keep their compact `-7L` form. - if (str_starts_with($code, '-')) { - return '-(' . $code . ')'; + // A bare numeric literal is a single C++ token; negating it directly + // cannot change the parse, and keeps the emitted code (and the test + // snapshots built on it) readable. + if (preg_match('/^(?:\d[\d\'.]*(?:[eE][+-]?\d+)?|0[xX][0-9a-fA-F\']+|0[bB][01\']+)(?:[uU]?[lL]{0,2})?$/', $code)) { + return '-' . $code; } - return '-' . $code; + // Parenthesize every other operand. An unparenthesized operand can + // change the C++ parse: `-($a ? $b : $c)` would emit + // `-cond ? b : c`, binding the minus to the condition and possibly + // selecting the wrong branch, and `- -$a` would paste into the C++ + // pre-decrement token `--a`. + return '-(' . $code . ')'; } protected function parseUnaryPlus(Expr\UnaryPlus $expr): string diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 8c91e1ce..ec47b4b8 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -28,6 +28,7 @@ use TypePhp\Transform\ClassFieldSelection; use TypePhp\Transform\FunctionAttributeLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; +use TypePhp\Resolver\ClassConstantValueTrait; use TypePhp\Transform\RuntimeAttributeFactoryLowering; use TypePhp\Transform\Visitor; use TypePhp\Transform\VoidCastValidationVisitor; @@ -46,6 +47,31 @@ class Preprocessor extends CompilerBase { + use ClassConstantValueTrait; + + /** + * Magic methods Zend rejects inside enum declarations. Enum cases are + * stateless singletons, so construction, destruction, cloning, (de)ser- + * ialization, string casting, and property magic are all forbidden; + * only __call, __callStatic, and __invoke remain legal. + */ + private const array ENUM_FORBIDDEN_MAGIC_METHODS = [ + '__construct' => true, + '__destruct' => true, + '__clone' => true, + '__get' => true, + '__set' => true, + '__unset' => true, + '__isset' => true, + '__sleep' => true, + '__wakeup' => true, + '__set_state' => true, + '__serialize' => true, + '__unserialize' => true, + '__tostring' => true, + '__debuginfo' => true, + ]; + protected string $targetName = 'app'; /** @@ -851,6 +877,11 @@ protected function parseParams(array $params, FunctionDef $functionDef): void if (!$this->classDef or !$this->methodDef or $this->methodDef->name !== '__construct') { $this->fatalError($param, 'Promoted properties are not supported'); } + // A variadic parameter collects arguments into an array, so no + // single value exists to promote into the property. + if ($param->variadic) { + $this->fatalError($param, 'Cannot declare variadic promoted property'); + } $nullable = $param->type instanceof NullableType; // Promoted property defaults belong to the constructor parameter, // not to the property default table. The property itself must stay @@ -988,6 +1019,15 @@ protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $ $returnTypeKeyword = $rtLower; } } + // `self`/`static` return types need a class scope; Zend rejects them + // on free functions at compile time. `parent` is already rejected in + // parseTypeDecl for every declaration context. + if (($returnTypeKeyword === 'self' || $returnTypeKeyword === 'static') + && $v instanceof Node\Stmt\Function_ + && $this->classDef === null + ) { + $this->fatalError($v->returnType, "Cannot use \"{$returnTypeKeyword}\" when no class scope is active"); + } [$returnType, $class] = $this->resolveTypeDecl($v->returnType, self::DECL_TYPE_OF_RETURN); $this->assertSupportedNativeObjectTypeNode($v->returnType, self::DECL_TYPE_OF_RETURN, $v); $nullableNativeReturn = $this->resolveNullableNativeObjectType( @@ -1198,6 +1238,11 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum if ($class instanceof Node\Stmt\Class_) { $flags = $class->flags; + } elseif ($class instanceof Node\Stmt\Enum_) { + // Zend marks every enum class entry ZEND_ACC_FINAL, which is what + // rejects `class B extends E`. Carrying the flag here lets the + // regular final-class inheritance check cover enums as well. + $flags = Modifiers::PUBLIC | Modifiers::FINAL; } else { $flags = Modifiers::PUBLIC; } @@ -1248,11 +1293,48 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum if ($class instanceof Node\Stmt\Enum_) { $this->classDef->enum = true; if ($class->scalarType !== null) { + $backingType = strtolower($class->scalarType->name); + if ($backingType !== 'int' && $backingType !== 'string') { + $this->fatalError( + $class->scalarType, + "Enum backing type must be `int` or `string`, `{$class->scalarType->name}` given", + ); + } $this->classDef->enumBackingType = $class->scalarType->name; } } if (!$class instanceof Node\Stmt\Trait_) { $this->classDef->implements = $this->parseImplements($class->implements); + $implemented = []; + foreach ($this->classDef->implements as $i => $interfaceName) { + $interfaceLower = strtolower($interfaceName); + $errorNode = $class->implements[$i] ?? $class; + if ($class instanceof Node\Stmt\Enum_ + && ($interfaceLower === 'unitenum' || $interfaceLower === 'backedenum') + ) { + // Zend adds UnitEnum (and BackedEnum for backed enums) + // itself; naming either explicitly is a compile-time error. + if ($interfaceLower === 'backedenum' && $this->classDef->enumBackingType === null) { + $this->fatalError( + $errorNode, + "Non-backed enum `{$fullClassName}` cannot implement interface `BackedEnum`", + ); + } + $interfaceDisplay = $interfaceLower === 'unitenum' ? 'UnitEnum' : 'BackedEnum'; + $this->fatalError( + $errorNode, + "Enum `{$fullClassName}` cannot implement previously implemented interface `{$interfaceDisplay}`", + ); + } + if (isset($implemented[$interfaceLower])) { + $kind = $class instanceof Node\Stmt\Enum_ ? 'Enum' : 'Class'; + $this->fatalError( + $errorNode, + "{$kind} `{$fullClassName}` cannot implement previously implemented interface `{$interfaceName}`", + ); + } + $implemented[$interfaceLower] = true; + } } else { $this->classDef->trait = $class; // Trait members are compiled later in the consuming class, but @@ -1326,7 +1408,20 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum break; case 'Stmt_EnumCase': $caseName = $this->parseIdentifier($v->name); - $this->classDef->enumCases[$caseName] = $v->expr?->value; + // Enum cases are class constants in Zend: a case may collide + // with another case or with a `const` of the same name. + if (array_key_exists($caseName, $this->classDef->enumCases) + || $this->classDef->hasConstant($caseName) + ) { + $this->fatalError($v, "Cannot redefine class constant `{$fullClassName}::{$caseName}`"); + } + if ($v->expr !== null && $this->classDef->enumBackingType === null) { + $this->fatalError($v, "Case `{$caseName}` of non-backed enum `{$fullClassName}` must not have a value"); + } + if ($v->expr === null && $this->classDef->enumBackingType !== null) { + $this->fatalError($v, "Case `{$caseName}` of backed enum `{$fullClassName}` must have a value"); + } + $this->classDef->enumCases[$caseName] = $this->evaluateEnumCaseValue($v); break; case 'Stmt_ClassMethod': $this->prepareClassMethod($v, $class); @@ -1542,6 +1637,13 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); + if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) { + $constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : ''; + $this->fatalError( + $v, + "Class constant `{$this->classDef->getNamespacedName(false)}::{$constName}` cannot have type `{$this->typeCheckNodeToString($v->type)}`", + ); + } [$declaredType, $class] = $v->type ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) : [null, '']; @@ -1629,6 +1731,41 @@ protected function resolveReferencedConstantType(Node\Expr\ClassConstFetch $fetc return null; } + /** + * Resolve an enum case's backing value at prepare time. A backed case may + * use any constant expression (`case A = 1 + 1;`), so reading the raw AST + * `->value` property is not enough: expression nodes have no such + * property, which emitted an "Undefined property" diagnostic and stored + * null (the pure-case marker) for a backed case. + */ + private function evaluateEnumCaseValue(Node\Stmt\EnumCase $case): int|string|null + { + $expr = $case->expr; + if ($expr === null) { + return null; + } + if ($expr instanceof Node\Scalar\Int_ || $expr instanceof Node\Scalar\String_) { + return $expr->value; + } + $constDef = new ConstantDef($this->parseIdentifier($case->name), 0, Type::VAR, ''); + $constDef->valueExpr = $expr; + try { + // Diagnostics must not terminate the compiler here: the value may + // reference a class declared later in the file (forward + // references are legal in PHP), which this early pass cannot + // resolve yet. + $value = $this->withThrowingDiagnostics( + fn (): mixed => $this->evaluateClassConstValue($case, $constDef, $this->getFullClassName(), $constDef->name) + ); + } catch (\Throwable) { + // The stub registration evaluates the expression independently; a + // value this evaluator cannot resolve only loses the compile-time + // placeholder, never the registered runtime case value. + return null; + } + return is_int($value) || is_string($value) ? $value : null; + } + private function parseClassLikeConstant(Node\Const_ $const, int $flags, string $type, string $class = '', ?string $declaredType = null): ConstantDef { $constName = $this->parseIdentifier($const->name); @@ -1669,7 +1806,31 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ ); } $flags = $this->parseModifiers($flags); + // A `readonly class` marks every property readonly, so the class-level + // flag participates in the same Zend declaration rules as an explicit + // per-property `readonly` modifier. + if (($flags | $this->classDef->flags) & Modifiers::READONLY) { + $className = $this->classDef->getNamespacedName(false); + if ($flags & Modifiers::STATIC) { + $this->fatalError($errorNode, "Static property `{$className}::\${$name}` cannot be readonly"); + } + if ($typeNode === null) { + $this->fatalError($errorNode, "Readonly property `{$className}::\${$name}` must have type"); + } + if ($defaultNode !== null) { + $this->fatalError($errorNode, "Readonly property `{$className}::\${$name}` cannot have default value"); + } + } $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); + // `callable` is a runtime-context type (a string or array may or may + // not be callable depending on scope), so Zend forbids it in property + // types entirely - bare, nullable, or as a union member. + if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) { + $this->fatalError( + $errorNode, + "Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`", + ); + } [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $nullableNative = $this->resolveNullableNativeObjectType( @@ -1741,6 +1902,164 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ return $propDef; } + /** + * Validate compound well-formedness before resolving, so every context a + * type declaration is parsed in (parameters, returns, properties, class + * and interface constants, closures) shares the same Zend rules. + */ + protected function resolveTypeDecl(?NodeAbstract $type, int $what): array + { + $this->validateCompoundTypeDecl($type); + return parent::resolveTypeDecl($type, $what); + } + + /** + * Compile-time well-formedness of compound type declarations, mirroring + * Zend: standalone-only types inside unions, invalid nullable targets, + * duplicate members (after alias/namespace resolution, with iterable + * expanded to array|Traversable), the bool/true/false overlaps, and + * non-class standard types inside intersections. Redundancy between whole + * DNF groups is not checked. + */ + private function validateCompoundTypeDecl(?NodeAbstract $type): void + { + if ($type instanceof NullableType) { + $inner = $type->type; + if (!$inner instanceof Node\Identifier && !$inner instanceof Node\Name) { + return; + } + $innerLower = strtolower($this->parseIdentifier($inner)); + if ($innerLower === 'mixed') { + $this->fatalError($type, 'Type `mixed` cannot be marked as nullable since mixed already includes null'); + } + if ($innerLower === 'null') { + $this->fatalError($type, '`null` cannot be marked as nullable'); + } + if ($innerLower === 'void' || $innerLower === 'never') { + $this->fatalError($type, "Type `{$innerLower}` can only be used as a standalone type"); + } + return; + } + if ($type instanceof UnionType) { + $this->validateUnionTypeDecl($type); + } elseif ($type instanceof IntersectionType) { + $this->validateIntersectionTypeDecl($type); + } + } + + private function validateUnionTypeDecl(UnionType $type): void + { + $seen = []; + $addMember = function (string $key, string $display, NodeAbstract $node) use (&$seen): void { + if (isset($seen[$key])) { + $this->fatalError($node, "Duplicate type `{$display}` is redundant"); + } + $seen[$key] = true; + }; + foreach ($type->types as $member) { + if ($member instanceof IntersectionType) { + // A DNF group: its members obey the intersection rules. + $this->validateIntersectionTypeDecl($member); + continue; + } + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'mixed' || $nameLower === 'void' || $nameLower === 'never') { + $this->fatalError($member, "Type `{$nameLower}` can only be used as a standalone type"); + } + if ($nameLower === 'bool' || $nameLower === 'false' || $nameLower === 'true') { + // Zend folds false/true into bool: a union may not repeat the + // overlap, and naming both literals asks for bool instead. + if (($nameLower === 'true' && isset($seen['false'])) + || ($nameLower === 'false' && isset($seen['true'])) + ) { + $this->fatalError($member, 'Type contains both `true` and `false`, `bool` must be used instead'); + } + if ($nameLower === 'bool') { + foreach (['false', 'true'] as $literal) { + if (isset($seen[$literal])) { + $this->fatalError($member, "Duplicate type `{$literal}` is redundant"); + } + } + } elseif (isset($seen['bool'])) { + $this->fatalError($member, "Duplicate type `{$nameLower}` is redundant"); + } + $addMember($nameLower, $nameLower, $member); + continue; + } + if ($nameLower === 'iterable') { + // Zend expands iterable to array|Traversable before the + // redundancy check and reports the overlapping component. + $addMember('iterable', 'iterable', $member); + $addMember('array', 'array', $member); + $addMember('traversable', 'Traversable', $member); + continue; + } + if (isset($this->zendTypeMap[$nameLower]) + || in_array($nameLower, ['self', 'parent', 'static'], true) + ) { + $addMember($nameLower, $nameLower, $member); + continue; + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $addMember(strtolower($resolved), $resolved, $member); + } + } + + private function validateIntersectionTypeDecl(IntersectionType $type): void + { + $seen = []; + foreach ($type->types as $member) { + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { + // Rejected later by buildTypeCheckFromNode with its + // established "cannot be part of an intersection type" text. + continue; + } + if (in_array($nameLower, [ + 'int', 'float', 'bool', 'false', 'true', 'string', 'array', + 'object', 'mixed', 'null', 'void', 'never', 'callable', 'iterable', + ], true)) { + $this->fatalError($member, "Type `{$nameLower}` cannot be part of an intersection type"); + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $resolvedLower = strtolower($resolved); + if (isset($seen[$resolvedLower])) { + $this->fatalError($member, "Duplicate type `{$resolved}` is redundant"); + } + $seen[$resolvedLower] = true; + } + } + + /** + * Whether a declared type mentions `callable` outside an intersection. + * Zend forbids callable in property and class-constant types; members of + * an intersection are rejected separately as non-class types. + */ + private function typeDeclContainsCallable(NodeAbstract $typeNode): bool + { + if ($typeNode instanceof NullableType) { + return $this->typeDeclContainsCallable($typeNode->type); + } + if ($typeNode instanceof UnionType) { + foreach ($typeNode->types as $member) { + if ($this->typeDeclContainsCallable($member)) { + return true; + } + } + return false; + } + if ($typeNode instanceof IntersectionType) { + return false; + } + return strtolower($this->parseIdentifier($typeNode)) === 'callable'; + } + private function validateAsymmetricPropertyDeclaration( string $name, int $flags, @@ -1996,6 +2315,12 @@ protected function propertyTypeDeclToString(NodeAbstract $typeNode): string protected function parseClassPropertyDef(Node\Stmt\Property $v): void { + // Zend enum class entries have no property table at all: instance, + // static, and hooked properties are all rejected at compile time. + if ($this->classDef->enum) { + $this->fatalError($v, "Enum `{$this->classDef->getNamespacedName(false)}` cannot include properties"); + } + $this->validateClassPropertyHookPlacement($v); $arrayDef = $this->parseArrayDefinition($v); if ($this->classDef->nativeObject) { if ($v->type === null) { @@ -2046,6 +2371,74 @@ protected function parseClassPropertyDef(Node\Stmt\Property $v): void $this->context = $oriCtx; } + /** + * Mirror Zend's compile-time placement rules for property hooks on class + * (and trait) properties; the interface path enforces its own subset in + * prepareInterfaceProperty(). Check order follows Zend 8.4 precedence: + * static, readonly, then the abstract-property rules. + */ + private function validateClassPropertyHookPlacement(Node\Stmt\Property $v): void + { + $abstract = (bool) ($v->flags & Modifiers::ABSTRACT); + if ($v->hooks === [] && !$abstract) { + return; + } + + $className = $this->classDef->getNamespacedName(false); + $propName = $v->props !== [] ? $this->parseIdentifier($v->props[0]->name) : ''; + if ($v->hooks !== []) { + if ($v->flags & Modifiers::STATIC) { + $this->fatalError($v, 'Cannot declare hooks for static property'); + } + // A readonly class marks every property readonly, exactly like an + // explicit per-property modifier. + if (($v->flags | $this->classDef->flags) & Modifiers::READONLY) { + $this->fatalError($v, 'Hooked properties cannot be readonly'); + } + } + + if ($abstract) { + if ($v->hooks === []) { + $this->fatalError($v, 'Only hooked properties may be declared abstract'); + } + foreach ($v->props as $prop) { + if ($prop->default !== null) { + $this->fatalError( + $v, + "Cannot specify default value for virtual hooked property {$className}::\${$propName}", + ); + } + } + $hasAbstractHook = false; + foreach ($v->hooks as $hook) { + if ($hook->body === null) { + $hasAbstractHook = true; + break; + } + } + if (!$hasAbstractHook) { + $this->fatalError( + $v, + "Abstract property `{$className}::\${$propName}` must specify at least one abstract hook", + ); + } + if (!$this->classDef->trait && !($this->classDef->flags & Modifiers::ABSTRACT)) { + $this->fatalError( + $v, + "Non-abstract class `{$className}` contains abstract hooked property `\${$propName}`", + ); + } + return; + } + + // Without the abstract modifier every declared hook needs a body. + foreach ($v->hooks as $hook) { + if ($hook->body === null) { + $this->fatalError($hook, 'Non-abstract property hook must have a body'); + } + } + } + protected function prepareClassMethod(Node\Stmt\ClassMethod $v, Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class): void { $this->resetMethod(); @@ -2053,6 +2446,15 @@ protected function prepareClassMethod(Node\Stmt\ClassMethod $v, Node\Stmt\Class_ $this->method = $name; $this->assertKeywordMethodMayBeDeclared($v, $name, $this->classDef->nativeObject); $this->assertNativeMagicMethodSupported($v, $name); + // Zend forbids every magic method in enums except __call, __callStatic, + // and __invoke: enum cases are singletons without state, construction, + // cloning, serialization, or property access. + if ($this->classDef->enum && isset(self::ENUM_FORBIDDEN_MAGIC_METHODS[strtolower($name)])) { + $this->fatalError( + $v, + "Enum `{$this->classDef->getNamespacedName(false)}` cannot include magic method `{$name}`", + ); + } $flags = $this->parseModifiers($v->flags); $abstract = $flags & Modifiers::ABSTRACT; if ($this->classDef->nativeObject && ($flags & Modifiers::STATIC)) { @@ -2094,6 +2496,22 @@ protected function prepareClassMethod(Node\Stmt\ClassMethod $v, Node\Stmt\Class_ if ($this->classDef->hasMethod($name) || $this->classDef->hasAbstractMethod($name)) { $this->fatalError($v, "Duplicate method `{$this->method}`"); } + // Enums can never be declared abstract, so an abstract method in an + // enum body can never be implemented (Zend rejects it at link time). + if ($class instanceof Node\Stmt\Enum_) { + $this->fatalError($v, "Enum `{$this->class}` cannot include abstract method `{$v->name}()`"); + } + // A private method cannot be overridden, so an abstract private + // method could never be implemented. Traits are exempt since PHP + // 8.0: the consuming class provides the private implementation. + if (!$class instanceof Node\Stmt\Trait_ && ($flags & Modifiers::PRIVATE)) { + $this->fatalError($v, "Abstract function `{$this->class}::{$name}()` cannot be declared private"); + } + // An abstract method declares a signature only; Zend rejects a body + // instead of silently discarding it. + if ($v->stmts !== null) { + $this->fatalError($v, "Abstract function `{$this->class}::{$name}()` cannot contain body"); + } if (!$class instanceof Node\Stmt\Trait_ && isset($class->flags) && !($class->flags & Modifiers::ABSTRACT)) { $this->fatalError($v, "Non-abstract class {$this->class} contains abstract method {$v->name}"); } @@ -2216,8 +2634,23 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void $interfaceName = $this->interfaceDef->getNamespacedName(false); $interfaceNameLower = strtolower($interfaceName); + $extendedInterfaces = []; foreach ($v->extends as $parent) { $parentName = $this->getNamespacedClassName($this->parseIdentifier($parent)); + // An interface may only extend interfaces. The parent's kind is + // only known once its declaration has been prepared; a parent + // declared later is validated by the Translator instead. + if ($this->hasClass($parentName) || $this->isInternalClass($parentName)) { + $this->fatalError($parent, "`{$interfaceName}` cannot implement `{$parentName}` - it is not an interface"); + } + $parentNameLower = strtolower($parentName); + if (isset($extendedInterfaces[$parentNameLower])) { + $this->fatalError( + $parent, + "Interface `{$interfaceName}` cannot implement previously implemented interface `{$parentName}`", + ); + } + $extendedInterfaces[$parentNameLower] = true; $this->interfaceDef->extendsList[] = $parentName; if ($this->interfaceDef->extends === '') { $this->interfaceDef->extends = $parentName; @@ -2239,6 +2672,18 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void if ($stmt instanceof Node\Stmt\ClassConst) { foreach ($stmt->consts as $const) { $constName = $this->parseIdentifier($const->name); + if ($stmt->flags & (Modifiers::PRIVATE | Modifiers::PROTECTED)) { + $this->fatalError( + $stmt, + "Access type for interface constant `{$interfaceName}::{$constName}` must be public", + ); + } + if ($stmt->type !== null && $this->typeDeclContainsCallable($stmt->type)) { + $this->fatalError( + $stmt, + "Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`", + ); + } if ($this->interfaceDef->hasConstant($constName)) { $this->fatalError($stmt, "Duplicate constant `{$constName}`"); } @@ -2261,6 +2706,20 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void if ($stmt instanceof Node\Stmt\ClassMethod) { $methodName = $this->getMethodName($stmt); $this->assertKeywordMethodMayBeDeclared($stmt, $methodName, false); + // Interface methods are implicitly public and abstract; Zend + // rejects the modifiers below in this exact precedence order. + if ($stmt->flags & (Modifiers::PRIVATE | Modifiers::PROTECTED)) { + $this->fatalError($stmt, "Access type for interface method `{$interfaceName}::{$methodName}()` must be public"); + } + if ($stmt->flags & Modifiers::ABSTRACT) { + $this->fatalError($stmt, "Interface method `{$interfaceName}::{$methodName}()` must not be abstract"); + } + if ($stmt->flags & Modifiers::FINAL) { + $this->fatalError($stmt, "Interface method `{$interfaceName}::{$methodName}()` must not be final"); + } + if ($stmt->stmts !== null) { + $this->fatalError($stmt, "Interface function `{$interfaceName}::{$methodName}()` cannot contain body"); + } if ($this->interfaceDef->hasMethod($methodName)) { $this->fatalError($stmt, "Duplicate method `{$methodName}`"); } @@ -2306,6 +2765,12 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void if ($property->hooks === []) { $this->fatalError($property, 'Interfaces may only include hooked properties'); } + if ($property->flags & Modifiers::ABSTRACT) { + $this->fatalError( + $property, + 'Property in interface cannot be explicitly abstract. All interface members are implicitly abstract', + ); + } if ($property->flags & (Modifiers::PRIVATE | Modifiers::PROTECTED)) { $this->fatalError($property, 'Property in interface cannot be protected or private'); } @@ -2354,6 +2819,12 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { $name = $this->parseIdentifier($prop->name); + if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) { + $this->fatalError( + $property, + "Property `{$this->interfaceDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($property->type)}`", + ); + } if ($property->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false)) { $this->fatalCompileTimeAttribute( $property, @@ -2393,7 +2864,12 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$aliases, array &$ignored): void { - foreach ($traitUse->adaptations as $adaptation) { + // Adaptation identity used to verify during trait composition that + // every alias matched a real trait method (an unqualified alias is + // registered under every used trait's key, so its variants share one + // group and the group is satisfied when ANY variant matches). + $groupBase = $traitUse->getAttribute('startFilePos', $traitUse->getStartLine()) . '@'; + foreach ($traitUse->adaptations as $adaptationIndex => $adaptation) { if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { $traits = []; if (!$adaptation->trait) { @@ -2416,6 +2892,9 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al $aliases[$this->getFullMethodName($traitName, $methodName)][] = [ 'newName' => $adaptation->newName ? $adaptation->newName->toString() : $methodName, 'newModifier' => $adaptation->newModifier ?: 0, + 'group' => $groupBase . $adaptationIndex, + 'method' => $methodName, + 'trait' => $adaptation->trait ? $traitName : null, ]; } } @@ -2424,6 +2903,7 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al $this->fatalError($traitUse, 'Trait precedence cannot be used without a trait'); } $methodName = $adaptation->method->toString(); + $winnerTrait = $this->getNamespacedClassName($this->parseIdentifier($adaptation->trait)); /* * For example: * use TraitA { TraitA::method insteadof TraitB} @@ -2431,7 +2911,13 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al */ foreach ($adaptation->insteadof as $trait2) { $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait2)); - $ignored[$this->getFullMethodName($traitName, $methodName)] = true; + // The value records the rule for existence validation + // during composition; consumers only use isset() on the key. + $ignored[$this->getFullMethodName($traitName, $methodName)] = [ + 'method' => $methodName, + 'winnerTrait' => $winnerTrait, + 'loserTrait' => $traitName, + ]; } } } diff --git a/src/Translator.php b/src/Translator.php index d40f3f3c..b11b24b3 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -43,7 +43,6 @@ use TypePhp\Platform\Wasi; use TypePhp\Platform\Windows; use TypePhp\Resolver\Reflection; -use TypePhp\Resolver\ClassConstantValueTrait; use TypePhp\Transform\Visitor; use TypePhp\Transform\ConstructorLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; @@ -66,7 +65,6 @@ class Translator extends Preprocessor use NativeCommandOptionsTrait; use SourcePipelineTrait; use ResourceCompilationTrait; - use ClassConstantValueTrait; public const string VERSION = '0.6.8'; public const string APP_NAME = 'TypePHP Compiler (AOT)'; @@ -82,6 +80,15 @@ class Translator extends Preprocessor // Windows resource file configuration (icon, version info, etc.) protected array $resourceConfig = []; + + /** + * Memoized effective constant tables (constant name => constant and its + * original declaring class/interface), keyed by lowercased class-like + * name. See getEffectiveConstantTable(). + * + * @var array> + */ + private array $effectiveConstantTables = []; protected array $globalHeaders = [ 'cstring', 'phpx.h', @@ -1141,6 +1148,8 @@ private function doGenExtension(): string $code .= '// class array constants' . PHP_EOL; $code .= $this->genClassArrayConstants(); + $code .= '// enum case class constants' . PHP_EOL; + $code .= $this->genClassEnumCaseConstants(false); $code .= '}' . PHP_EOL . PHP_EOL; // module_init end @@ -1227,6 +1236,9 @@ private function doGenExtension(): string } } + $code .= '// enum case class constants' . PHP_EOL; + $code .= $this->genClassEnumCaseConstants(true); + // User-code symbols have request lifetime regardless of the build mode. // Embedded/library hosts may start more than one Zend request in the // same process, so never let these pointers survive RSHUTDOWN. @@ -2399,6 +2411,141 @@ protected function getFilesFromDir(string $path): array return $scanner->scan(); } + /** + * Register class constants whose value is an enum case. + * + * Enum case objects have request lifetime, so the MINIT-registered zval + * can only hold a scalar placeholder (the backing value or case name), + * which dynamic access (`constant('K::CB')`, `$cls::CB`, reflection) + * would observe instead of the case object. Mirror the array-constant + * mechanism: write the real case object with php::updateConstant() on + * every request init, and reset the slot to null on request shutdown so + * no request-bound object dangles inside the persistent class entry. + */ + protected function genClassEnumCaseConstants(bool $cleanup): string + { + $code = ''; + $emit = function (object $classDef, ConstantDef $constant, object $ownerDef) use (&$code, $cleanup): void { + $case = $this->resolveEnumCaseClassConstant($ownerDef, $constant); + if ($case === null) { + return; + } + [$enumClass, $caseName] = $case; + $classNameStr = $this->genCharPtr($classDef->getNamespacedName(false), true); + $classConstStr = $this->genCharPtr($constant->name); + if ($cleanup) { + $code .= "php::updateConstant($classNameStr, $classConstStr, php::null);\n"; + return; + } + $enumCe = 'php::getClassEntrySafe(' . $this->genCharPtr($enumClass, true) . ')'; + $code .= "php::updateConstant($classNameStr, $classConstStr, " + . "php::getEnumCase($enumCe, " . $this->genCharPtr($caseName) . "));\n"; + }; + + foreach ($this->getClassLikesWithConstants() as $classDef) { + if ($classDef instanceof ClassDef && $classDef->nativeObject) { + continue; + } + foreach ($classDef->constants as $constant) { + $emit($classDef, $constant, $classDef); + } + } + + // Child class entries hold their own copy of the inherited constant + // placeholder, so constants inherited from parents and interfaces + // must be updated on the child explicitly, like array constants. + foreach ($this->symbols->classes() as $classDef) { + if ($classDef->nativeObject) { + continue; + } + $ownConstNames = []; + foreach ($classDef->constants as $constant) { + $ownConstNames[$constant->name] = true; + } + + $parentName = $this->escapeClass($classDef->extends); + while ($parentName && $this->symbols->hasClass($parentName)) { + $parentDef = $this->symbols->class($parentName); + foreach ($parentDef->constants as $constant) { + if (!isset($ownConstNames[$constant->name])) { + $ownConstNames[$constant->name] = true; + $emit($classDef, $constant, $parentDef); + } + } + $parentName = $this->escapeClass($parentDef->extends); + } + + foreach ($this->getClassImplementedInterfaces($classDef) as $interfaceName) { + if (!$this->hasInterface($interfaceName)) { + continue; + } + $interfaceDef = $this->getInterface($interfaceName); + foreach ($interfaceDef->constants as $constant) { + if (!isset($ownConstNames[$constant->name])) { + $ownConstNames[$constant->name] = true; + $emit($classDef, $constant, $interfaceDef); + } + } + } + } + + return $code; + } + + /** + * Resolve a class constant whose value is an enum case fetch, possibly + * through a chain of other class constants (`const A = Other::B` where + * `Other::B = E::C`). + * + * @return array{string, string}|null [enum class name, case name] + */ + protected function resolveEnumCaseClassConstant(object $ownerDef, ConstantDef $constant): ?array + { + $ownerClass = ltrim($ownerDef->getNamespacedName(false), '\\'); + $expr = $constant->valueExpr; + for ($depth = 0; $depth < 16; $depth++) { + if (!$expr instanceof Node\Expr\ClassConstFetch + || !$expr->class instanceof Node\Name + || !$expr->name instanceof Node\Identifier + ) { + return null; + } + $constName = $expr->name->toString(); + if (strcasecmp($constName, 'class') === 0) { + return null; + } + $className = $this->resolveClassConstFetchOwner($expr->class, $ownerClass); + if ($className === null || !$this->hasClass($className)) { + return null; + } + $targetDef = $this->getClass($className); + if ($targetDef->enum && array_key_exists($constName, $targetDef->enumCases)) { + return [$targetDef->getNamespacedName(false), $constName]; + } + if (!$targetDef->hasConstant($constName)) { + return null; + } + $expr = $targetDef->getConstant($constName)->valueExpr; + $ownerClass = $className; + } + return null; + } + + private function resolveClassConstFetchOwner(Node\Name $name, string $ownerClass): ?string + { + $raw = $name->toString(); + if (strcasecmp($raw, 'self') === 0 || strcasecmp($raw, 'static') === 0) { + return $ownerClass; + } + if (strcasecmp($raw, 'parent') === 0) { + return $this->hasClass($ownerClass) && $this->getClass($ownerClass)->extends !== '' + ? $this->getClass($ownerClass)->extends + : null; + } + $resolved = $name->getAttribute('resolvedName'); + return ltrim($resolved instanceof Node\Name ? $resolved->toString() : $raw, '\\'); + } + protected function genClassArrayConstants(): string { $code = ''; @@ -2855,6 +3002,8 @@ protected function doConvert(string $phpCode): string $this->parseConstDef($v); } elseif ($v instanceof Node\Stmt\Interface_) { $this->validateInterfaceOverrideAttributes($v); + $this->validateInterfaceConstants($v); + $this->validateInterfaceMethodCompatibility($v); } elseif (!$v instanceof Node\Stmt\Nop) { $this->unsupportedSyntax($v); } @@ -3029,6 +3178,8 @@ protected function parseNamespace(Node\Stmt\Namespace_ $node): string $this->parseGroupUse($v2); } elseif ($v2 instanceof Node\Stmt\Interface_) { $this->validateInterfaceOverrideAttributes($v2); + $this->validateInterfaceConstants($v2); + $this->validateInterfaceMethodCompatibility($v2); } elseif (!$v2 instanceof Node\Stmt\Nop) { $this->unsupportedSyntax($v2); } @@ -3067,6 +3218,8 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) $traitMethods = []; $traitConstants = []; $traitProperties = []; + $usedTraits = []; + $seenTraitMethods = []; $consumingClass = $className->toString(); $classDef = $this->getClass($consumingClass); @@ -3104,6 +3257,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) if (!$traitDef->trait) { $this->fatalError($classStmt, "Trait `{$traitFullName}` not found"); } + $usedTraits[strtolower($traitFullName)] = $traitFullName; /** @var Node\Stmt\Trait_ $traitAst */ $traitAst = $this->cloneAstNode($traitDef->trait); @@ -3124,6 +3278,10 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) $traitStmt->setAttribute(self::TRAIT_METHOD_ATTRIBUTE, $traitStmt->name->toString()); } $fullMethodName = $this->getFullMethodName($traitFullName, $methodName); + // Methods arriving from nested traits are keyed under + // the directly-used trait, matching how adaptation + // keys are registered. + $seenTraitMethods[$fullMethodName] = true; // A trait method's `self`/`static`/`parent` return and parameter // types refer to the class that uses the trait, not the trait // itself. Re-resolve them on the cloned AST so the generated @@ -3265,10 +3423,11 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) continue; } if (isset($traitConstants[$constName])) { - [$existingConstStmt, $existingConst] = $traitConstants[$constName]; + [$existingConstStmt, $existingConst, $existingConstTrait] = $traitConstants[$constName]; + $typeStr = $this->typeNodeToStringOrNull($traitStmt->type); if ($existingConstStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - $this->printer->prettyPrintExpr($existingConst->value) !== $this->printer->prettyPrintExpr($const->value)) { + $this->typeNodeToStringOrNull($existingConstStmt->type) !== $typeStr || + !$this->isSameTraitMemberValue($existingConst->value, $existingConstTrait, $const->value, $traitFullName, $typeStr)) { $this->fatalError($classStmt, "Trait `{$traitFullName}` constant `{$constName}` already exists"); } unset($traitStmt->consts[$k2]); @@ -3277,7 +3436,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } continue; } - $traitConstants[$constName] = [$traitStmt, $const]; + $traitConstants[$constName] = [$traitStmt, $const, $traitFullName]; } } if ($traitStmt instanceof Node\Stmt\Property) { @@ -3291,12 +3450,13 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) continue; } if (isset($traitProperties[$propName])) { - [$existingPropStmt, $existingProp] = $traitProperties[$propName]; - $existingDefault = $existingProp->default ? $this->printer->prettyPrintExpr($existingProp->default) : null; - $propDefault = $prop->default ? $this->printer->prettyPrintExpr($prop->default) : null; + [$existingPropStmt, $existingProp, $existingPropTrait] = $traitProperties[$propName]; + $typeStr = $this->typeNodeToStringOrNull($traitStmt->type); if ($existingPropStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - $existingDefault !== $propDefault) { + $this->typeNodeToStringOrNull($existingPropStmt->type) !== $typeStr || + ($existingProp->default === null) !== ($prop->default === null) || + ($prop->default !== null + && !$this->isSameTraitMemberValue($existingProp->default, $existingPropTrait, $prop->default, $traitFullName, $typeStr))) { $this->fatalError($classStmt, "Trait `{$traitFullName}` property `{$propName}` already exists"); } unset($traitStmt->props[$k2]); @@ -3305,7 +3465,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } continue; } - $traitProperties[$propName] = [$traitStmt, $prop]; + $traitProperties[$propName] = [$traitStmt, $prop, $traitFullName]; } } } @@ -3314,6 +3474,127 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } } + $this->validateTraitAdaptations($stmt, $classDef, $usedTraits, $seenTraitMethods); + } + + /** + * After every trait is composed into $classDef, verify that each trait + * adaptation named a real trait and a real method, as Zend does when + * binding traits: + * + * - an alias must reference a used trait, and its method must exist in + * that trait (in any used trait when written without a qualifier); + * - a precedence rule's traits must all be used, and the preferred + * method must exist in the preferred trait (the overridden trait need + * not declare it). + * + * @param array $usedTraits lowercased name => full name + * @param array $seenTraitMethods "trait::method" keys seen + * during composition (nested trait methods + * are keyed under the directly-used trait) + */ + private function validateTraitAdaptations( + Node\Stmt\ClassLike $stmt, + ClassDef $classDef, + array $usedTraits, + array $seenTraitMethods + ): void { + if (!$classDef->traitAliases && !$classDef->traitIgnored) { + return; + } + $className = $classDef->getNamespacedName(false); + + // An unqualified alias is registered under every used trait's key (the + // Preprocessor cannot know which trait declares the method), so its + // variants share one group: the group is satisfied when ANY variant + // matched a composed method. + $aliasGroups = []; + foreach ($classDef->traitAliases as $fullMethodName => $aliasList) { + foreach ($aliasList as $alias) { + $group = $alias['group'] ?? $fullMethodName; + $aliasGroups[$group] ??= ['alias' => $alias, 'matched' => false]; + if (isset($seenTraitMethods[$fullMethodName])) { + $aliasGroups[$group]['matched'] = true; + } + } + } + foreach ($aliasGroups as $groupInfo) { + if ($groupInfo['matched']) { + continue; + } + $alias = $groupInfo['alias']; + $method = $alias['method'] ?? ''; + $explicitTrait = $alias['trait'] ?? null; + if ($explicitTrait !== null) { + if (!isset($usedTraits[strtolower($explicitTrait)])) { + $this->fatalError($stmt, + "Required Trait `{$explicitTrait}` wasn't added to `{$className}`"); + } + $this->fatalError($stmt, + "An alias was defined for `{$explicitTrait}::{$method}` but this method does not exist"); + } + $newName = $alias['newName'] ?? $method; + $this->fatalError($stmt, + "An alias (`{$newName}`) was defined for method `{$method}()`, but this method does not exist"); + } + + foreach ($classDef->traitIgnored as $rule) { + if (!is_array($rule)) { + continue; + } + foreach ([$rule['winnerTrait'], $rule['loserTrait']] as $traitName) { + if (!isset($usedTraits[strtolower($traitName)])) { + $this->fatalError($stmt, + "Required Trait `{$traitName}` wasn't added to `{$className}`"); + } + } + if (!isset($seenTraitMethods[$this->getFullMethodName($rule['winnerTrait'], $rule['method'])])) { + $this->fatalError($stmt, + "A precedence rule was defined for `{$rule['winnerTrait']}::{$rule['method']}` " . + 'but this method does not exist'); + } + } + } + + /** + * Compare two trait data-member initializers by VALUE, as Zend does when + * flattening traits: `1 + 1` and `2`, or `[1, 2]` and `array(1, 2)`, are + * the same definition. Comparison is identity (===) after evaluating both + * constant expressions; an integer initializer of a float-typed member is + * coerced to float first, mirroring Zend's declaration-time coercion. + * Falls back to source-text equality when a value cannot be evaluated at + * compile time. + */ + private function isSameTraitMemberValue( + Node\Expr $existingValue, + string $existingClass, + Node\Expr $incomingValue, + string $incomingClass, + ?string $declaredTypeStr, + ): bool { + try { + $a = $this->evaluateTraitMemberValue($existingValue, $existingClass); + $b = $this->evaluateTraitMemberValue($incomingValue, $incomingClass); + } catch (\Throwable) { + return $this->printer->prettyPrintExpr($existingValue) === $this->printer->prettyPrintExpr($incomingValue); + } + if ($declaredTypeStr !== null + && (strcasecmp($declaredTypeStr, 'float') === 0 || strcasecmp($declaredTypeStr, '?float') === 0)) { + if (is_int($a)) { + $a = (float) $a; + } + if (is_int($b)) { + $b = (float) $b; + } + } + return $a === $b; + } + + private function evaluateTraitMemberValue(Node\Expr $expr, string $class): mixed + { + $constDef = new ConstantDef('', 0, '', ''); + $constDef->valueExpr = $expr; + return $this->evaluateClassConstValue($expr, $constDef, $class, ''); } /** @@ -3867,6 +4148,16 @@ protected function parseClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ if ($parent->flags & Modifiers::FINAL) { $this->fatalError($class, "Class `{$this->class}` cannot extend final class `{$parentClass}`"); } + // Readonly-ness is part of the inheritance contract in both + // directions (Zend: a readonly class seals its property + // semantics for the whole hierarchy). + $childReadonly = (bool) ($this->classDef->flags & Modifiers::READONLY); + $parentReadonly = (bool) ($parent->flags & Modifiers::READONLY); + if ($childReadonly !== $parentReadonly) { + $this->fatalError($class, $parentReadonly + ? "Non-readonly class `{$this->class}` cannot extend readonly class `{$parentClass}`" + : "Readonly class `{$this->class}` cannot extend non-readonly class `{$parentClass}`"); + } } else { $this->fatalError($class, "Class `{$this->class}` inherits from a non-existent class `{$parentClass}`"); } @@ -3944,6 +4235,8 @@ protected function parseClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ if (!$class instanceof Node\Stmt\Trait_) { $this->validateOverrideAttributes($class); $this->checkInterfaceImplementations($class); + $this->checkInheritedConstantContracts($class); + $this->checkInterfaceMethodCollisions($class); $this->checkInheritedAbstractMethodsAreImplemented($class); } $code = $this->genNativeMethod($methodCodes); @@ -4393,13 +4686,22 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): /** * Check whether a parent method can be overridden: private methods cannot be - * overridden, and the signature must be compatible. + * overridden, and the signature must be compatible. With $childIsAbstract, + * the declaration additionally may not turn a concrete inherited method + * abstract (Zend: "Cannot make non abstract method ... abstract"). */ - protected function checkParentMethodCanBeOverridden(Node\Stmt\ClassMethod $v, string $name): void - { - if ($name === '__construct') { - return; - } + protected function checkParentMethodCanBeOverridden( + Node\Stmt\ClassMethod $v, + string $name, + bool $childIsAbstract = false + ): void { + // Zend exempts constructors from the LSP checks against a CONCRETE + // parent constructor (subclasses may freely change the construction + // signature and even narrow its visibility). A FINAL parent + // constructor still cannot be overridden — even a final private one — + // and an ABSTRACT parent constructor imposes a real signature + // contract, exactly like an interface constructor. + $isConstructor = strtolower($name) === '__construct'; $classDef = $this->classDef; $childFuncDef = $this->methodDef->functionDef; @@ -4411,23 +4713,36 @@ protected function checkParentMethodCanBeOverridden(Node\Stmt\ClassMethod $v, st // The parent class is a built-in class if ($classDef->inheritedFromInternalClass) { $modifiers = Reflection::getClassMethodModifiers($extends, $name); - if ($modifiers & \ReflectionMethod::IS_PRIVATE) { - goto _error; + if (!$isConstructor && ($modifiers & \ReflectionMethod::IS_PRIVATE)) { + // Private methods are not inherited: a child may redeclare + // one with any signature. Zend ignores FINAL on private + // methods outside constructors (declaring one only raises + // a warning), so no final check applies here either. + break; } if ($modifiers & \ReflectionMethod::IS_FINAL) { goto _final_error; } + if ($childIsAbstract && $modifiers !== null && !($modifiers & \ReflectionMethod::IS_ABSTRACT)) { + $this->fatalError($v, + "Cannot make non abstract method `{$extends}::{$name}()` abstract in class `{$this->getFullClassName()}`"); + } + if (!$isConstructor) { + $this->validateInternalMethodOverrideSignature($v, $name, $this->methodDef, $extends); + } break; } $classDef = $this->getClass($extends); if ($classDef->hasMethod($name)) { $methodDef = $classDef->getMethod($name); - if ($methodDef->flags & Modifiers::PRIVATE) { - _error: - $message = 'Cannot override private method `' . $extends . '::' . $name . '()`'; - $this->fatalGeneratedMethodAttributeIfAny($v, $message, $extends, $name); - $this->fatalError($v, - $message); + if (!$isConstructor && ($methodDef->flags & Modifiers::PRIVATE)) { + // See the internal-parent branch above: a private method + // may be redeclared freely. Generated code stays correct + // because private calls are devirtualized to the declaring + // class's body (canDevirtualize()), matching PHP's + // private-scope binding, and Native classes never give + // private methods a virtual slot (isNativeVirtualMethod()). + break; } if ($methodDef->flags & Modifiers::FINAL) { _final_error: @@ -4446,10 +4761,18 @@ protected function checkParentMethodCanBeOverridden(Node\Stmt\ClassMethod $v, st $this->fatalError($v, $message); } - $this->validateMethodOverrideSignature($v, $name, $this->methodDef, $methodDef, $extends); + if ($childIsAbstract) { + $this->fatalError($v, + "Cannot make non abstract method `{$extends}::{$name}()` abstract in class `{$this->getFullClassName()}`"); + } + if (!$isConstructor) { + $this->validateMethodOverrideSignature($v, $name, $this->methodDef, $methodDef, $extends); + } break; } if ($classDef->hasAbstractMethod($name) && isset($classDef->abstractMethodDefs[strtolower($name)])) { + // An abstract parent constructor is validated like an + // interface constructor: its signature is a contract. $this->validateMethodOverrideSignature($v, $name, $this->methodDef, $classDef->getAbstractMethod($name), $extends); break; } @@ -4461,9 +4784,10 @@ protected function validateMethodOverrideSignature( string $methodName, MethodDef $childMethodDef, MethodDef $parentMethodDef, - string $parentClass + string $parentClass, + ?string $childClass = null ): void { - $className = $this->getFullClassName(); + $className = $childClass ?? $this->getFullClassName(); // PHP allows widening visibility in overrides (e.g. protected -> public), // but forbids narrowing it. @@ -4514,7 +4838,10 @@ protected function validateMethodOverrideSignature( )) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } - if ($childFuncDef->returnsByRef !== $parentFuncDef->returnsByRef) { + // Zend treats by-ref returns as covariant: an override may add `&` + // (callers expecting a value still work), but it must not drop one + // promised by the parent contract. + if ($parentFuncDef->returnsByRef && !$childFuncDef->returnsByRef) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } @@ -4524,12 +4851,33 @@ protected function validateMethodOverrideSignature( $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } - // Compare each parent-declared parameter position. - foreach ($parentFuncDef->argInfoList as $i => $parentArg) { - if (!isset($childFuncDef->argInfoList[$i])) { - $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + // A variadic parent accepts unbounded arguments, so Zend requires the + // override to be variadic as well. + $parentVariadic = $parentFuncDef->hasVariadicArg(); + $childVariadic = $childFuncDef->hasVariadicArg(); + if ($parentVariadic && !$childVariadic) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + + // Compare each parent-declared parameter position. Following Zend's + // inheritance check, a trailing child variadic stands in for every + // remaining parent position (the decorator pattern), and when the + // parent is variadic each extra child parameter is validated against + // the parent's variadic slot. + $positions = count($parentFuncDef->argInfoList); + if ($parentVariadic) { + $positions = max($positions, count($childFuncDef->argInfoList)); + } + for ($i = 0; $i < $positions; $i++) { + $parentArg = $parentFuncDef->argInfoList[$i] + ?? $parentFuncDef->argInfoList[count($parentFuncDef->argInfoList) - 1]; + $childArg = $childFuncDef->argInfoList[$i] ?? null; + if ($childArg === null) { + if (!$childVariadic) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + $childArg = $childFuncDef->argInfoList[count($childFuncDef->argInfoList) - 1]; } - $childArg = $childFuncDef->argInfoList[$i]; if ($parentArg->immutable && !$childArg->immutable) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } @@ -4539,9 +4887,6 @@ protected function validateMethodOverrideSignature( if ($childArg->byRef !== $parentArg->byRef) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } - if ($childArg->variadic !== $parentArg->variadic) { - $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); - } } // Any extra child parameters must be optional or variadic. @@ -4606,6 +4951,199 @@ private function fatalGeneratedMethodAttributeIfAny( )); } + /** + * Validate an override of a method inherited from a Zend built-in class. + * The parent signature is only available through host reflection, so this + * mirrors validateMethodOverrideSignature() (and Zend's + * zend_do_perform_implementation_check) on ReflectionMethod data: + * visibility may widen but not narrow, staticness must match, a by-ref + * return may be added but not dropped, the child may not require more + * arguments, parameters are contravariant with invariant by-ref-ness and + * variadic absorption, and the return type is covariant. Built-in methods + * whose return type is TENTATIVE are exempt from the return check: Zend + * only raises a deprecation for a tentative mismatch, never a fatal. + */ + private function validateInternalMethodOverrideSignature( + Node\Stmt\ClassMethod $v, + string $methodName, + MethodDef $childMethodDef, + string $parentClass + ): void { + $parentRef = Reflection::getClass($parentClass); + if (!$parentRef || !$parentRef->hasMethod($methodName)) { + return; + } + try { + $parentMethod = $parentRef->getMethod($methodName); + } catch (\ReflectionException) { + return; + } + $className = $this->getFullClassName(); + + $parentVisibility = $parentMethod->isPublic() + ? Modifiers::PUBLIC + : ($parentMethod->isProtected() ? Modifiers::PROTECTED : Modifiers::PRIVATE); + if ($this->getVisibilityRank($childMethodDef->flags) < $this->getVisibilityRank($parentVisibility)) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + + if ((($childMethodDef->flags & Modifiers::STATIC) !== 0) !== $parentMethod->isStatic()) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + + $childFuncDef = $childMethodDef->functionDef; + if (!$childFuncDef) { + return; + } + + // By-ref returns are covariant: the override may add `&`, but must not + // drop one promised by the built-in parent. + if ($parentMethod->returnsReference() && !$childFuncDef->returnsByRef) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + + if ($childFuncDef->argCountRequired > $parentMethod->getNumberOfRequiredParameters()) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + + $parentParams = $parentMethod->getParameters(); + $parentVariadic = $parentMethod->isVariadic(); + $childVariadic = $childFuncDef->hasVariadicArg(); + if ($parentVariadic && !$childVariadic) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + + $positions = count($parentParams); + if ($parentVariadic) { + $positions = max($positions, count($childFuncDef->argInfoList)); + } + for ($i = 0; $i < $positions; $i++) { + $parentParam = $parentParams[$i] ?? $parentParams[count($parentParams) - 1]; + $childArg = $childFuncDef->argInfoList[$i] ?? null; + if ($childArg === null) { + if (!$childVariadic) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + $childArg = $childFuncDef->argInfoList[count($childFuncDef->argInfoList) - 1]; + } + if ($childArg->byRef !== $parentParam->isPassedByReference()) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + if (!$this->isParameterTypeOverrideCompatibleWithReflection($childArg, $parentParam, $parentClass)) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + } + + // Any extra child parameters must be optional or variadic. + for ($i = count($parentParams); $i < count($childFuncDef->argInfoList); $i++) { + $childArg = $childFuncDef->argInfoList[$i]; + if (!$childArg->variadic && $childArg->defaultValue === null) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + } + + // getReturnType() is null for tentative return types, so only real + // declared return types are enforced here — matching Zend, which + // fatals on real mismatches and merely deprecates tentative ones. + $parentReturn = $parentMethod->getReturnType(); + if ($parentReturn === null) { + return; + } + if ($childFuncDef->returnTypeUndeclared) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + $parentTypes = $this->reflectionTypeToAcceptedTypes($parentReturn, $parentClass); + $childTypes = $this->getReturnAcceptedTypes($childFuncDef, $className); + foreach ($childTypes as $childType) { + if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + } + } + + private function isParameterTypeOverrideCompatibleWithReflection( + ArgInfo $childArg, + \ReflectionParameter $parentParam, + string $parentClass + ): bool { + // A child accepting anything is always contravariant-compatible. + if ($this->isTopParameterType($childArg)) { + return true; + } + $parentType = $parentParam->getType(); + if ($parentType === null) { + // Untyped built-in parameter: the parent accepts anything, so a + // narrower child type breaks the contract. + return false; + } + $childAccepted = $this->getParameterAcceptedTypes($childArg); + if ($childAccepted === null) { + // The child type cannot be modelled in the accepted-types DNF; + // stay permissive rather than reject a potentially valid program. + return true; + } + $parentAccepted = $this->reflectionTypeToAcceptedTypes($parentType, $parentClass); + return $this->isAcceptedTypeSubset($parentAccepted, $childAccepted); + } + + /** + * Map a host ReflectionType (named, nullable, union or intersection) into + * the accepted-types DNF used by the override comparison machinery. + * + * @return list> + */ + private function reflectionTypeToAcceptedTypes(\ReflectionType $type, string $declaringClass): array + { + if ($type instanceof \ReflectionUnionType) { + $entries = []; + foreach ($type->getTypes() as $member) { + foreach ($this->reflectionTypeToAcceptedTypes($member, $declaringClass) as $entry) { + $entries[] = $entry; + } + } + return $entries; + } + if ($type instanceof \ReflectionIntersectionType) { + $members = []; + foreach ($type->getTypes() as $member) { + $members[] = $this->reflectionNamedTypeEntry($member, $declaringClass); + } + return [['kind' => 'allOf', 'types' => $members]]; + } + /** @var \ReflectionNamedType $type */ + $entries = [$this->reflectionNamedTypeEntry($type, $declaringClass)]; + if ($type->allowsNull() && !in_array(strtolower($type->getName()), ['mixed', 'null'], true)) { + $entries[] = ['kind' => 'isNull']; + } + return $entries; + } + + /** @return array */ + private function reflectionNamedTypeEntry(\ReflectionNamedType $type, string $declaringClass): array + { + $name = strtolower($type->getName()); + return match ($name) { + 'int' => ['kind' => 'isInt'], + 'float' => ['kind' => 'isFloat'], + 'string' => ['kind' => 'isString'], + 'bool' => ['kind' => 'isBool'], + 'array' => ['kind' => 'isArray'], + 'object' => ['kind' => 'isObject'], + 'mixed' => ['kind' => 'isMixed'], + 'void' => ['kind' => 'isVoid'], + 'never' => ['kind' => 'isNever'], + 'null' => ['kind' => 'isNull'], + 'true' => ['kind' => 'isTrue'], + 'false' => ['kind' => 'isFalse'], + 'callable' => ['kind' => 'callable'], + 'iterable' => ['kind' => 'iterable'], + 'static' => ['kind' => 'isStatic', 'class' => $declaringClass], + 'self' => ['kind' => 'instanceof', 'class' => $declaringClass], + 'parent' => ['kind' => 'instanceof', 'class' => get_parent_class($declaringClass) ?: $declaringClass], + default => ['kind' => 'instanceof', 'class' => $type->getName()], + }; + } + private function isReturnTypeOverrideCompatible( FunctionDef $childFuncDef, FunctionDef $parentFuncDef, @@ -5215,6 +5753,14 @@ private function checkPropertyOverride(Node\Stmt\Class_|Node\Stmt\Trait_|Node\St "`{$parentClass}::\${$name}`; property shadowing across inheritance is not allowed"); } $matchedOverrides[$name] = true; + // A static property and an instance property are different + // kinds of storage; Zend forbids redeclaring one as the + // other in either direction. + if (($childProp->flags & Modifiers::STATIC) !== ($parentProp->flags & Modifiers::STATIC)) { + $this->fatalError($classStmt, ($parentProp->flags & Modifiers::STATIC) + ? "Cannot redeclare static `{$parentClass}::\${$name}` as non static `{$className}::\${$name}`" + : "Cannot redeclare non static `{$parentClass}::\${$name}` as static `{$className}::\${$name}`"); + } // PHP inherits get and set independently. A child may // override only one hook, or redeclare the property // without hooks while retaining both parent hooks. @@ -5281,6 +5827,436 @@ private function getPropertySetVisibilityRank(PropertyDef $property): int return $this->getVisibilityRank($property->flags); } + /** + * Parse a class constant declaration and additionally record the + * accepted-types DNF of its declared type. The base implementation + * collapses composite declared types (unions, nullables, intersections) + * to a single variant type, which is too coarse for the covariant + * constant-override checks; the DNF preserves the full declaration. + */ + protected function parseClassConstDef(Node\Stmt\ClassConst $v): void + { + parent::parseClassConstDef($v); + if ($v->type === null) { + return; + } + $typeInfo = $this->buildTypeCheckFromNode($v->type, true); + foreach ($v->consts as $const) { + $constName = $this->parseIdentifier($const->name); + if ($this->classDef !== null && $this->classDef->hasConstant($constName)) { + $constDef = $this->classDef->getConstant($constName); + $constDef->typeCheck = $typeInfo['check']; + $constDef->typeStr = $typeInfo['typeStr']; + } + } + } + + /** + * Parse an interface declaration and additionally record the + * accepted-types DNF of each typed constant, mirroring the + * parseClassConstDef() override above. The base implementation collapses + * composite declared types to a single variant type. + */ + protected function parseInterface(Node\Stmt\Interface_ $v): void + { + parent::parseInterface($v); + $name = $this->parseIdentifier($v->name); + $interfaceName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name; + if (!$this->hasInterface($interfaceName)) { + return; + } + $interfaceDef = $this->getInterface($interfaceName); + foreach ($v->stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\ClassConst || $stmt->type === null) { + continue; + } + $typeInfo = $this->buildTypeCheckFromNode($stmt->type, true); + foreach ($stmt->consts as $const) { + $constName = $this->parseIdentifier($const->name); + if (isset($interfaceDef->constants[$constName])) { + $interfaceDef->constants[$constName]->typeCheck = $typeInfo['check']; + $interfaceDef->constants[$constName]->typeStr = $typeInfo['typeStr']; + } + } + } + } + + /** + * Accepted-types DNF for a constant's DECLARED type, or null when the + * constant is untyped (no type contract to enforce on overrides). + */ + private function getConstantAcceptedTypes(ConstantDef $const): ?array + { + if ($const->declaredType === null) { + return null; + } + if ($const->typeCheck !== []) { + return $const->typeCheck; + } + return match ($const->declaredType) { + Type::INT => [['kind' => 'isInt']], + Type::FLOAT => [['kind' => 'isFloat']], + Type::BOOL => [['kind' => 'isBool']], + Type::STR => [['kind' => 'isString']], + Type::ARRAY => [['kind' => 'isArray']], + Type::RESOURCE => [['kind' => 'isResource']], + Type::OBJECT => $const->class !== '' + ? [['kind' => 'instanceof', 'class' => $const->class]] + : [['kind' => 'isObject']], + default => [['kind' => 'isMixed']], + }; + } + + /** + * PHP 8.3 typed class constants are covariant: an override may narrow the + * declared type but never widen it or move to an unrelated type. + */ + private function isConstantTypeOverrideCompatible(ConstantDef $childConst, array $parentAccepted): bool + { + if ($childConst->declaredType === null) { + return false; + } + foreach ($this->getConstantAcceptedTypes($childConst) as $childType) { + if (!$this->isReturnTypeCoveredBy($childType, $parentAccepted)) { + return false; + } + } + return true; + } + + /** + * The constants visible on a class-like, keyed by name, each entry + * carrying its ConstantDef and the ORIGINAL declaring class or interface. + * Mirrors Zend's constants-table build order (parent class first, own + * declarations, then interfaces). Conflicts between ancestors are resolved + * silently here — first entry wins — because they are reported by the + * declaring type's own validation pass when it is compiled. + * + * @return array + */ + private function getEffectiveConstantTable(ClassDef|InterfaceDef $def): array + { + $ownName = $def->getNamespacedName(false); + $key = strtolower($ownName); + if (isset($this->effectiveConstantTables[$key])) { + return $this->effectiveConstantTables[$key]; + } + $table = []; + if ($def instanceof ClassDef) { + if ($def->extends !== '' && !$def->inheritedFromInternalClass && $this->hasClass($def->extends)) { + foreach ($this->getEffectiveConstantTable($this->getClass($def->extends)) as $name => $entry) { + // Private constants are not inherited. + if (!($entry['const']->flags & Modifiers::PRIVATE)) { + $table[$name] = $entry; + } + } + } + foreach ($def->constants as $name => $const) { + $table[$name] = ['const' => $const, 'origin' => $ownName]; + } + $parents = $def->implements; + } else { + foreach ($def->constants as $name => $const) { + $table[$name] = ['const' => $const, 'origin' => $ownName]; + } + $parents = $def->extendsList ?: ($def->extends ? [$def->extends] : []); + } + foreach ($parents as $interfaceName) { + if (!$this->hasInterface($interfaceName)) { + continue; + } + foreach ($this->getEffectiveConstantTable($this->getInterface($interfaceName)) as $name => $entry) { + $table[$name] ??= $entry; + } + } + return $this->effectiveConstantTables[$key] = $table; + } + + /** + * Memoized effective method tables of interfaces (method name => def and + * its original declaring interface), mirroring getEffectiveConstantTable(). + * + * @var array> + */ + private array $effectiveInterfaceMethodTables = []; + + /** @return array */ + private function getEffectiveInterfaceMethodTable(InterfaceDef $def): array + { + $ownName = $def->getNamespacedName(false); + $key = strtolower($ownName); + if (isset($this->effectiveInterfaceMethodTables[$key])) { + return $this->effectiveInterfaceMethodTables[$key]; + } + $table = []; + foreach ($def->methods as $name => $methodDef) { + $table[$name] = ['def' => $methodDef, 'origin' => $ownName]; + } + foreach ($def->extendsList ?: ($def->extends ? [$def->extends] : []) as $parentName) { + if (!$this->hasInterface($parentName)) { + continue; + } + foreach ($this->getEffectiveInterfaceMethodTable($this->getInterface($parentName)) as $name => $entry) { + $table[$name] ??= $entry; + } + } + return $this->effectiveInterfaceMethodTables[$key] = $table; + } + + /** + * Zend's interface merge validates the FIRST-seen declaration of a method + * as an override of every LATER same-name declaration ("Declaration of + * I1::f() must be compatible with I2::f()"): the interface's own method, + * or the one inherited from the earliest-listed parent, is the child. + */ + private function validateInterfaceMethodCompatibility(Node\Stmt\Interface_ $interfaceStmt): void + { + $name = $this->parseIdentifier($interfaceStmt->name); + $interfaceName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name; + if (!$this->hasInterface($interfaceName)) { + return; + } + $interfaceDef = $this->getInterface($interfaceName); + + $table = []; + foreach ($interfaceDef->methods as $methodName => $methodDef) { + $table[$methodName] = ['def' => $methodDef, 'origin' => $interfaceName]; + } + foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parentName) { + if (!$this->hasInterface($parentName)) { + continue; + } + foreach ($this->getEffectiveInterfaceMethodTable($this->getInterface($parentName)) as $methodName => $entry) { + if (!isset($table[$methodName])) { + $table[$methodName] = $entry; + continue; + } + $existing = $table[$methodName]; + if ($existing['origin'] === $entry['origin']) { + continue; // diamond: same original declaration + } + $this->validateMethodOverrideSignature( + $interfaceStmt, + $existing['def']->name, + $existing['def'], + $entry['def'], + $entry['origin'], + $existing['origin'], + ); + } + } + } + + /** + * When a class(-like) implements several interfaces declaring the same + * method and neither the class nor a userland ancestor defines it, Zend + * still validates the interface declarations against each other + * (first-seen as the child). A defined method silences this pairwise + * check — it is instead validated against every interface individually. + */ + private function checkInterfaceMethodCollisions(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void + { + $classDef = $this->classDef; + $definedInChain = function (string $methodName) use ($classDef): bool { + $current = $classDef; + while (true) { + if ($current->hasMethod($methodName) || $current->hasAbstractMethod($methodName)) { + return true; + } + if ($current->extends === '' || $current->inheritedFromInternalClass || !$this->hasClass($current->extends)) { + return false; + } + $current = $this->getClass($current->extends); + } + }; + + $table = []; + foreach ($this->getClassImplementedInterfaces($classDef) as $interfaceName) { + if (!$this->hasInterface($interfaceName)) { + continue; + } + foreach ($this->getEffectiveInterfaceMethodTable($this->getInterface($interfaceName)) as $methodName => $entry) { + if (!isset($table[$methodName])) { + $table[$methodName] = $entry; + continue; + } + $existing = $table[$methodName]; + if ($existing['origin'] === $entry['origin'] || $definedInChain($methodName)) { + continue; + } + $this->validateMethodOverrideSignature( + $classStmt, + $existing['def']->name, + $existing['def'], + $entry['def'], + $entry['origin'], + $existing['origin'], + ); + } + } + } + + /** + * Validate the class's constants against every constant contract arriving + * through an interface — implemented directly, inherited through a parent + * interface, or carried by an ancestor class (Zend keeps the original + * declaring interface in the inherited constants table, so a final or + * typed interface constant binds every transitive subclass): + * + * - a final interface constant cannot be overridden; + * - the same constant name arriving from two different declarations is + * ambiguous unless the class declares it itself; + * - an override of an interface constant must stay public; + * - typed constants are covariant (see checkConstantOverride()). + */ + private function checkInheritedConstantContracts(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void + { + $classDef = $this->classDef; + $className = $classDef->getNamespacedName(false); + + // Constants visible before this class's interfaces are merged: the + // parent chain's effective table, then the class's own declarations. + $table = []; + if ($classDef->extends !== '' && !$classDef->inheritedFromInternalClass && $this->hasClass($classDef->extends)) { + foreach ($this->getEffectiveConstantTable($this->getClass($classDef->extends)) as $name => $entry) { + if (!($entry['const']->flags & Modifiers::PRIVATE)) { + $table[$name] = $entry; + } + } + } + foreach ($classDef->constants as $name => $const) { + if (isset($table[$name]) && $this->hasInterface($table[$name]['origin'])) { + // The nearest inherited declaration originates in an interface + // reached through an ancestor class; class-chain declarations + // are validated by checkConstantOverride() instead. + $this->validateConstantAgainstInheritedEntry( + $classStmt, + 'Class', + $className, + $name, + ['const' => $const, 'origin' => $className], + $table[$name], + ); + } + $table[$name] = ['const' => $const, 'origin' => $className]; + } + + foreach ($classDef->implements as $interfaceName) { + if (!$this->hasInterface($interfaceName)) { + continue; + } + foreach ($this->getEffectiveConstantTable($this->getInterface($interfaceName)) as $name => $entry) { + if (!isset($table[$name])) { + $table[$name] = $entry; + continue; + } + $this->validateConstantAgainstInheritedEntry( + $classStmt, + 'Class', + $className, + $name, + $table[$name], + $entry, + ); + } + } + } + + /** + * Validate an interface's own constants against the ones inherited from + * its parent interfaces, and inherited same-name constants against each + * other (Zend: ambiguous unless declared by the interface itself). + */ + private function validateInterfaceConstants(Node\Stmt\Interface_ $interfaceStmt): void + { + $name = $this->parseIdentifier($interfaceStmt->name); + $interfaceName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name; + if (!$this->hasInterface($interfaceName)) { + return; + } + $interfaceDef = $this->getInterface($interfaceName); + + // An interface can only extend other interfaces; naming a class here + // is a Zend fatal, not a lookup failure. + foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parentName) { + if (!$this->hasInterface($parentName) && !$this->isInternalInterface($parentName) && $this->hasClass($parentName)) { + $this->fatalError($interfaceStmt, + "`{$interfaceName}` cannot implement `{$parentName}` - it is not an interface"); + } + } + + $table = []; + foreach ($interfaceDef->constants as $constName => $const) { + $table[$constName] = ['const' => $const, 'origin' => $interfaceName]; + } + foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parentName) { + if (!$this->hasInterface($parentName)) { + continue; + } + foreach ($this->getEffectiveConstantTable($this->getInterface($parentName)) as $constName => $entry) { + if (!isset($table[$constName])) { + $table[$constName] = $entry; + continue; + } + $this->validateConstantAgainstInheritedEntry( + $interfaceStmt, + 'Interface', + $interfaceName, + $constName, + $table[$constName], + $entry, + ); + } + } + } + + /** + * Zend's do_inherit_constant_check: $existing is the constant already in + * the type's table (its own declaration, or one inherited earlier), and + * $incoming the same-name constant arriving from another declaration. + * + * @param array{const: ConstantDef, origin: string} $existing + * @param array{const: ConstantDef, origin: string} $incoming + */ + private function validateConstantAgainstInheritedEntry( + NodeAbstract $node, + string $kind, + string $typeName, + string $constName, + array $existing, + array $incoming, + ): void { + if ($existing['origin'] === $incoming['origin']) { + // The same original declaration reached through two paths + // (diamond inheritance) never conflicts. + return; + } + if ($incoming['const']->flags & Modifiers::FINAL) { + $this->fatalError($node, + "`{$existing['origin']}::{$constName}` cannot override final constant " . + "`{$incoming['origin']}::{$constName}`"); + } + if ($existing['origin'] !== $typeName) { + $this->fatalError($node, + "{$kind} `{$typeName}` inherits both `{$existing['origin']}::{$constName}` and " . + "`{$incoming['origin']}::{$constName}`, which is ambiguous"); + } + + // The type's own declaration overrides the inherited constant. + $childConst = $existing['const']; + if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($incoming['const']->flags)) { + $this->fatalError($node, + "Access level to `{$typeName}::{$constName}` must be public " . + "(as in interface `{$incoming['origin']}`)"); + } + $parentAccepted = $this->getConstantAcceptedTypes($incoming['const']); + if ($parentAccepted !== null && !$this->isConstantTypeOverrideCompatible($childConst, $parentAccepted)) { + $this->fatalError($node, + "Declaration of `{$typeName}::{$constName}` must be compatible " . + "with `{$incoming['origin']}::{$constName}`"); + } + } + private function checkConstantOverride(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $classStmt): void { $classDef = $this->classDef; @@ -5305,30 +6281,16 @@ private function checkConstantOverride(Node\Stmt\Class_|Node\Stmt\Trait_|Node\St // PHP only enforces type compatibility when the parent constant // carries an explicit declared type. Overriding an untyped constant // with a value of any type is permitted, so the type check is skipped - // in that case. Visibility is always enforced below. - if ($parentConst->declaredType !== null) { - if ($childConst->declaredType === null) { - $this->fatalError($classStmt, - "Declaration of `{$className}::{$name}` must be compatible " . - "with `{$parentClass}::{$name}`"); - } - // An untyped child constant whose value is an expression (e.g. - // `X = ParentClass::Y`) is inferred as a variant. Resolve its real - // type from the referenced constant so the compatibility check uses - // the actual value type. - $childType = $childConst->type; - if ($childType === Type::VAR - && $childConst->valueExpr instanceof Node\Expr\ClassConstFetch) { - $resolved = $this->resolveReferencedConstantType($childConst->valueExpr, $this->getFullClassName()); - if ($resolved !== null) { - $childType = $resolved; - } - } - if ($childType !== $parentConst->type || $childConst->class !== $parentConst->class) { - $this->fatalError($classStmt, - "Declaration of `{$className}::{$name}` must be compatible " . - "with `{$parentClass}::{$name}`"); - } + // in that case. Typed constants are covariant (PHP 8.3): the child + // may narrow the declared type (e.g. int|string -> int) but never + // widen it or move to an unrelated type. Visibility is always + // enforced below. + $parentAccepted = $this->getConstantAcceptedTypes($parentConst); + if ($parentAccepted !== null + && !$this->isConstantTypeOverrideCompatible($childConst, $parentAccepted)) { + $this->fatalError($classStmt, + "Declaration of `{$className}::{$name}` must be compatible " . + "with `{$parentClass}::{$name}`"); } if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($parentConst->flags)) { $this->fatalError($classStmt, @@ -5360,6 +6322,20 @@ protected function parseClassMethod(Node\Stmt\ClassMethod $v, array &$methodCode // only run in the implementation phase. $this->checkParentMethodCanBeOverridden($v, $name); $methodCodes[$name] = $this->parseFunction($v); + } elseif ($this->classDef->trait === null + && !is_string($v->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE)) + && $this->classDef->hasAbstractMethod($name) + && isset($this->classDef->abstractMethodDefs[strtolower($name)]) + ) { + // An abstract method the class itself declares participates in + // the parent checks: it cannot turn a concrete inherited method + // abstract, and it must stay compatible with an inherited + // abstract contract. Abstract requirements arriving from traits + // are exempt — Zend lets an inherited concrete method satisfy + // them. + $this->methodDef = $this->classDef->getAbstractMethod($name); + $this->methodDef->node = $v; + $this->checkParentMethodCanBeOverridden($v, $name, childIsAbstract: true); } $this->resetMethod(); @@ -5482,20 +6458,57 @@ private function withTraitNameContext(string $traitName, callable $callback): mi private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $incoming): bool { - return $existing->flags === $incoming->flags - && $existing->type === $incoming->type - && $existing->class === $incoming->class - && $existing->value === $incoming->value; + if ($existing->flags !== $incoming->flags + || $existing->type !== $incoming->type + || $existing->class !== $incoming->class + ) { + return false; + } + if ($existing->value === $incoming->value) { + return true; + } + // Different spellings of the same value (e.g. `1 + 1` and `2`) are + // compatible in Zend; compare the evaluated values. + if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) { + $floatOnly = $existing->declaredType === Type::FLOAT + || strcasecmp($existing->typeStr, 'float') === 0 + || strcasecmp($existing->typeStr, '?float') === 0; + return $this->isSameTraitMemberValue( + $existing->valueExpr, + $this->getFullClassName(), + $incoming->valueExpr, + $this->getFullClassName(), + $floatOnly ? 'float' : null, + ); + } + return false; } private function isCompatibleTraitProperty(PropertyDef $existing, PropertyDef $incoming): bool { - return $existing->flags === $incoming->flags - && $existing->type === $incoming->type - && $existing->class === $incoming->class - && $existing->nullable === $incoming->nullable - && $existing->default === $incoming->default - && $existing->arrayDef == $incoming->arrayDef; + if ($existing->flags !== $incoming->flags + || $existing->type !== $incoming->type + || $existing->class !== $incoming->class + || $existing->nullable !== $incoming->nullable + ) { + return false; + } + if ($existing->default === $incoming->default && $existing->arrayDef == $incoming->arrayDef) { + return true; + } + // Different spellings of the same default value (e.g. `1` and `1.0` + // on a float property, `[1, 2]` and `array(1, 2)`) are compatible in + // Zend; compare the evaluated values. + if ($existing->defaultExpr instanceof Node\Expr && $incoming->defaultExpr instanceof Node\Expr) { + return $this->isSameTraitMemberValue( + $existing->defaultExpr, + $this->getFullClassName(), + $incoming->defaultExpr, + $this->getFullClassName(), + $existing->type === Type::FLOAT ? 'float' : null, + ); + } + return false; } private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string diff --git a/src/gen_stub.php b/src/gen_stub.php index f140dad8..25533823 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2828,6 +2828,13 @@ public function getCExpr(): ?string // leaking heredoc/nowdoc source syntax into generated C++. return '"' . getTranslator()->escapeString((string) $this->value) . '"'; } elseif ($this->type->isInt()) { + // PHP_INT_MIN cannot be spelled as one negative literal: C parses + // "-9223372036854775808" as negation applied to an out-of-range + // positive literal, which is ill-formed. Reuse the ZEND_LONG_MIN + // macro, exactly like the expression path (genIntegerLiteral). + if ($this->value === PHP_INT_MIN) { + return 'ZEND_LONG_MIN'; + } return strval($this->value); } elseif ($this->type->isFloat()) { return getTranslator()->genFloatLiteral((float) $this->value); diff --git a/tests/compiler/coalesce/assign-coalesce-compound-rhs-side-effect.phpt b/tests/compiler/coalesce/assign-coalesce-compound-rhs-side-effect.phpt new file mode 100644 index 00000000..cce33885 --- /dev/null +++ b/tests/compiler/coalesce/assign-coalesce-compound-rhs-side-effect.phpt @@ -0,0 +1,32 @@ +--TEST-- +??= does not evaluate a compound side-effecting RHS when the target is set +--FILE-- + +--EXPECT-- +int(1) +side effect! +int(42) +string(3) "set" diff --git a/tests/compiler/const/php-int-max-namespace-shadow.phpt b/tests/compiler/const/php-int-max-namespace-shadow.phpt new file mode 100644 index 00000000..d25063de --- /dev/null +++ b/tests/compiler/const/php-int-max-namespace-shadow.phpt @@ -0,0 +1,32 @@ +--TEST-- +Namespaced PHP_INT_MAX shadows the global constant in unqualified fetches +--FILE-- + +--EXPECT-- +int(6) +float(9.223372036854776E+18) +float(9.223372036854776E+18) diff --git a/tests/compiler/enum/enum-case-class-constant.phpt b/tests/compiler/enum/enum-case-class-constant.phpt new file mode 100644 index 00000000..54e56e4f --- /dev/null +++ b/tests/compiler/enum/enum-case-class-constant.phpt @@ -0,0 +1,49 @@ +--TEST-- +Enum cases stored in class constants keep their case identity +--FILE-- +value); + var_dump(constant('K::CB') === E::B); + $cls = 'K'; + var_dump($cls::CB === E::B); + var_dump(K::CB instanceof E); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +int(2) +bool(true) +bool(true) +bool(true) diff --git a/tests/compiler/float_edge/decimal-literal-classification.phpt b/tests/compiler/float_edge/decimal-literal-classification.phpt new file mode 100644 index 00000000..41ecb2e5 --- /dev/null +++ b/tests/compiler/float_edge/decimal-literal-classification.phpt @@ -0,0 +1,32 @@ +--TEST-- +Auto-Decimal literal classification: significant digits, hex, float mixing +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +float(2.098829548031543E+19) +bool(true) +bool(true) +bool(false) diff --git a/tests/compiler/object_property/native-int-property-assign-op-var.phpt b/tests/compiler/object_property/native-int-property-assign-op-var.phpt index da908531..6bbd4169 100644 --- a/tests/compiler/object_property/native-int-property-assign-op-var.phpt +++ b/tests/compiler/object_property/native-int-property-assign-op-var.phpt @@ -25,6 +25,7 @@ function main(): void } catch (TypeError $e) { var_dump($e->getMessage()); } + var_dump($box->value); $bad = any("abc"); try { @@ -41,6 +42,6 @@ function main(): void ?> --EXPECT-- int(3) -string(73) "Cannot assign string to property NativeIntAssignOpBox::$value of type int" -string(73) "Cannot assign string to property NativeIntAssignOpBox::$value of type int" +int(6) +string(39) "Unsupported operand types: int + string" int(6) diff --git a/tests/compiler/operator/eval-order-side-effects.phpt b/tests/compiler/operator/eval-order-side-effects.phpt new file mode 100644 index 00000000..1361d40f --- /dev/null +++ b/tests/compiler/operator/eval-order-side-effects.phpt @@ -0,0 +1,68 @@ +--TEST-- +Call arguments and concat operands follow Zend's operand read order around side effects +--FILE-- + +--EXPECT-- +string(3) "1,5" +int(5) +string(3) "2,6" +int(6) +string(3) "1,9" +int(9) +string(3) "bbb" +string(4) "xxxy" +string(3) "pbb" +string(4) "pa,b" +string(5) "preaz" +int(10) 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/typed-compound-assign.phpt b/tests/compiler/operator/typed-compound-assign.phpt new file mode 100644 index 00000000..09aafbb4 --- /dev/null +++ b/tests/compiler/operator/typed-compound-assign.phpt @@ -0,0 +1,84 @@ +--TEST-- +Compound assignment and ++/-- follow PHP semantics on typed and untyped slots +--FILE-- +getMessage() . "\n"; + } + return $a; +} + +function shiftNeg(int $a): int +{ + try { + $a <<= -1; + } catch (ArithmeticError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + return $a; +} + +function divEven(int $a): string +{ + $a /= 2; + return (string) $a; +} + +function postIncPair(int $a): string +{ + $b = $a++; + return $b . ',' . $a; +} + +function loopSum(int $n): int +{ + $sum = 0; + for ($i = 0; $i < $n; $i++) { + $sum += $i; + } + return $sum; +} + +function main(): void +{ + var_dump(modZero(5)); + var_dump(shiftNeg(4)); + echo divEven(8), "\n"; + echo postIncPair(5), "\n"; + var_dump(loopSum(5)); + + $x = 7; + $x /= 2; + var_dump($x); + + $y = PHP_INT_MAX; + $y += 1; + var_dump($y); + + $z = 5; + try { + $z %= 0; + } catch (DivisionByZeroError $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + var_dump($z); +} +?> +--EXPECT-- +caught: Modulo by zero +int(5) +caught: Bit shift by negative number +int(4) +4 +5,6 +int(10) +float(3.5) +float(9.223372036854776E+18) +caught: Modulo by zero +int(5) 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/operator/unary-minus-parens.phpt b/tests/compiler/operator/unary-minus-parens.phpt new file mode 100644 index 00000000..8464b182 --- /dev/null +++ b/tests/compiler/operator/unary-minus-parens.phpt @@ -0,0 +1,35 @@ +--TEST-- +Unary minus applies to the whole operand expression +--FILE-- + +--EXPECT-- +int(-2) +int(-3) +int(5) +int(-5) +int(-4) +int(-7) 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