diff --git a/phpunit/code/inheritance_error_const_final.php b/phpunit/code/inheritance_error_const_final.php new file mode 100644 index 00000000..e2e57cc7 --- /dev/null +++ b/phpunit/code/inheritance_error_const_final.php @@ -0,0 +1,13 @@ +exec('must be compatible', 'inheritance_error_const_type.php'); } + public function testTypedConstantCannotBeOverriddenWithoutDeclaredType() + { + $this->exec('must be compatible', 'inheritance_error_const_missing_type.php'); + } + + public function testFinalConstantCannotBeOverridden() + { + $this->exec('Cannot override final constant', 'inheritance_error_const_final.php'); + } + public function testConstantVisibilityMismatch() { $this->exec('must be compatible', 'inheritance_error_const_visibility.php'); diff --git a/src/Entity/ConstantDef.php b/src/Entity/ConstantDef.php index 6c192322..ed587ee9 100644 --- a/src/Entity/ConstantDef.php +++ b/src/Entity/ConstantDef.php @@ -19,6 +19,8 @@ class ConstantDef public string $arrayExpr = ''; public string $class = ''; public ?NodeAbstract $valueExpr = null; + /** Explicit declared type (e.g. `const int FOO`); null for inferred/untyped constants. */ + public ?string $declaredType = null; public function __construct(string $name, int $flags, string $type, string $value) { diff --git a/src/Parser/ClassConstantFetchTrait.php b/src/Parser/ClassConstantFetchTrait.php index 5d16ecb5..d5b01a94 100644 --- a/src/Parser/ClassConstantFetchTrait.php +++ b/src/Parser/ClassConstantFetchTrait.php @@ -31,6 +31,14 @@ trait ClassConstantFetchTrait $self = true; $class = $this->class; } + } elseif ($class === 'parent') { + if (!$this->classDef || !$this->classDef->extends) { + $this->fatalError($expr, 'Cannot use "parent" outside a class or class does not extend any class'); + } + // extends is already fully resolved. Keep the leading slash so the + // current namespace is not applied again below. + $class = '\\' . $this->classDef->extends; + $self = true; } $const = $this->escapeString($this->parseIdentifier($expr->name)); @@ -98,4 +106,3 @@ trait ClassConstantFetchTrait } } - diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 0273d2cd..f2253b45 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -757,24 +757,89 @@ class Preprocessor extends CompilerBase 'Scalar_String' => Type::STR, default => Type::VAR, }; + // `::class` is a compile-time magic constant that always yields a string, + // so a constant declared as `X = self::class` (or `Foo::class`) must be + // typed as a string rather than a generic variant. + if ($type === Type::VAR + && $const->value instanceof Node\Expr\ClassConstFetch + && strtolower((string) $const->value->name) === 'class') { + $type = Type::STR; + } + // A constant whose value references another class constant + // (e.g. `X = ParentClass::Y` or `X = self::Y`) must take the referenced + // constant's type. This keeps override compatibility checks and the C++ + // declaration correct, mirroring PHP where overriding an untyped constant + // with a value of any (compatible) type is allowed. + if ($type === Type::VAR + && $const->value instanceof Node\Expr\ClassConstFetch + && $const->value->class instanceof Node\Name) { + $refType = $this->resolveReferencedConstantType($const->value, $this->getFullClassName()); + if ($refType !== null) { + $type = $refType; + } + } } $constName = $this->parseIdentifier($const->name); if ($this->classDef->hasConstant($constName)) { $this->fatalError($v, "Duplicate constant `{$constName}`"); } - $constInfo = $this->parseClassLikeConstant($const, $flags, $type, $class); + $constInfo = $this->parseClassLikeConstant($const, $flags, $type, $class, $declaredType); $constInfo->class = $class; $this->classDef->constants[$constInfo->name] = $constInfo; } } - private function parseClassLikeConstant(Node\Const_ $const, int $flags, string $type, string $class = ''): ConstantDef + /** + * Resolve the compile-time type of a class constant whose value is a + * `ClassConstFetch` referencing another constant (e.g. `X = ParentClass::Y` + * or `X = self::Y`). Returns the referenced constant's type, or null when + * the reference cannot be resolved yet (for instance when the referenced + * class has not been prepared). `::class` always resolves to a string. + */ + private function resolveReferencedConstantType(Node\Expr\ClassConstFetch $fetch, string $currentClass): ?string + { + $constName = $fetch->name->toString(); + if (strcasecmp($constName, 'class') === 0) { + return Type::STR; + } + if (!($fetch->class instanceof Node\Name)) { + return null; + } + $className = $fetch->class->toString(); + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + $targetClass = $currentClass; + } elseif (strcasecmp($className, 'parent') === 0) { + $targetClass = $this->getParentClass($currentClass); + } else { + $targetClass = $this->getNamespacedClassName($className); + } + if ($targetClass === '' || !$this->hasClass($targetClass)) { + return null; + } + $def = $this->getClass($targetClass); + if (!$def->hasConstant($constName)) { + return null; + } + $refConst = $def->getConstant($constName); + // Follow the chain in case the referenced constant is itself an + // expression that resolves to another constant. + if ($refConst->type !== Type::VAR) { + return $refConst->type; + } + if ($refConst->valueExpr instanceof Node\Expr\ClassConstFetch) { + return $this->resolveReferencedConstantType($refConst->valueExpr, $targetClass); + } + return null; + } + + private function parseClassLikeConstant(Node\Const_ $const, int $flags, string $type, string $class = '', ?string $declaredType = null): ConstantDef { $constName = $this->parseIdentifier($const->name); $constValue = $this->parseIdentifier($const->value); $constInfo = new ConstantDef($constName, $flags, $type, $constValue); $constInfo->valueExpr = $const->value; + $constInfo->declaredType = $declaredType; if ($this->context->beforeStmtLines) { $arrayExpr = ''; @@ -979,7 +1044,7 @@ class Preprocessor extends CompilerBase default => Type::VAR, }; } - $constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class); + $constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class, $stmt->type ? $type : null); $this->interfaceDef->constants[$constName] = $constInfo; } continue; diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 0853e993..a674fd7a 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -116,6 +116,16 @@ trait ClassConstantValueTrait if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) { $constName = $expr->name->toString(); $className = $expr->class->toString(); + if (strcasecmp($constName, 'class') === 0) { + // `::class` is a compile-time magic constant that resolves to the + // fully qualified class name of the referenced class. + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + $className = $class; + } elseif (strcasecmp($className, 'parent') === 0) { + $className = $this->getParentClass($class); + } + return ltrim($this->getNamespacedClassName($className, $this->getNamespaceOfClass($class)), '\\'); + } if (strcasecmp($className, 'self') === 0) { $className = $class; } elseif (strcasecmp($className, 'parent') === 0) { diff --git a/src/Translator.php b/src/Translator.php index e7c3838a..aa6e2eb7 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3478,10 +3478,37 @@ CODE; if ($parentConst->flags & Modifiers::PRIVATE) { continue; } - if ($childConst->type !== $parentConst->type || $childConst->class !== $parentConst->class) { + if ($parentConst->flags & Modifiers::FINAL) { $this->fatalError($classStmt, - "Declaration of `{$className}::{$name}` must be compatible " . - "with `{$parentClass}::{$name}`"); + "Cannot override final constant `{$parentClass}::{$name}`"); + } + // 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}`"); + } } if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($parentConst->flags)) { $this->fatalError($classStmt, diff --git a/src/gen_stub.php b/src/gen_stub.php index f266157f..752c6b9b 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2354,18 +2354,29 @@ class EvaluatedValue } if ($expr instanceof Expr\ClassConstFetch) { + $constName = $expr->name->__toString(); + if (strcasecmp($constName, 'class') === 0) { + // `::class` is a compile-time magic constant that resolves to the + // fully qualified class name of the referenced class. + $className = getClassConstFetchClassName($expr); + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + return ClassInfo::$currentClass; + } + if (strcasecmp($className, 'parent') === 0) { + return getTranslator()->getParentClass(ClassInfo::$currentClass); + } + return ltrim($className, '\\'); + } $class = getClassConstFetchClassName($expr); if ($class === 'self') { - $constName = ClassInfo::$currentClass . "::" . $expr->name->__toString(); + $constName = ClassInfo::$currentClass . "::" . $constName; if (isset($allConstInfos[$constName])) { return formatConstValue($allConstInfos[$constName]->getValue($allConstInfos)->value); } else { - return formatConstValue(getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $expr->name->toString())); + return formatConstValue(getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $constName)); } - } elseif ($expr->name->__toString() === 'class') { - return $class; } else { - return formatConstValue(getTranslator()->getClassConstValue($expr, $class, $expr->name->__toString(), ClassInfo::$currentClass)); + return formatConstValue(getTranslator()->getClassConstValue($expr, $class, $constName, ClassInfo::$currentClass)); } } else { $constName = $expr->name->__toString(); @@ -2490,16 +2501,14 @@ class EvaluatedValue // PHP single-quote to C double-quote string if ($this->type->isString()) { - if ( - $this->expr instanceof PhpParser\Node\Expr\ClassConstFetch - ) { - if ($this->expr->class instanceof PhpParser\Node\Name\FullyQualified and - $this->expr->name instanceof PhpParser\Node\Identifier and + if ($this->expr instanceof PhpParser\Node\Expr\ClassConstFetch) { + if ($this->expr->name instanceof PhpParser\Node\Identifier and $this->expr->name->__toString() === 'class') { - $expr = '"' . addcslashes($this->expr->class->name, '\\') . '"'; - } else { - return $this->value; + // `::class` is a compile-time magic constant that resolves to the + // fully qualified class name (already stored in $this->value). + return '"' . addcslashes($this->value, '\\') . '"'; } + return $this->value; } elseif ($this->expr instanceof Expr\ConstFetch) { return getTranslator()->getConstValue($this->expr->name->toString()); } elseif (!($this->expr instanceof String_)) { @@ -2899,6 +2908,12 @@ class ConstInfo extends VariableLike $code .= "\tzend_string *const_{$constName}_name = zend_string_init_interned(\"$constName\", sizeof(\"$constName\") - 1, true);\n"; $nameCode = "const_{$constName}_name"; + // A child class may override a constant inherited from its parent. The + // runtime copies the parent's constants into the child, so re-declaring + // the constant would fail with "Cannot redefine class constant". + // Drop any inherited entry first so the child's value replaces it. + $code .= "\tzend_hash_del(&class_entry->constants_table, $nameCode);\n"; + if ($this->exposedDocComment) { $commentCode = "const_{$constName}_comment"; $escapedCommentInit = $this->exposedDocComment->getInitCode(); diff --git a/tests/compiler/const/class-const-override-variants.phpt b/tests/compiler/const/class-const-override-variants.phpt new file mode 100644 index 00000000..32a085e4 --- /dev/null +++ b/tests/compiler/const/class-const-override-variants.phpt @@ -0,0 +1,51 @@ +--TEST-- +class const override variants (self::class, parent::class, references, multi-level) +--FILE-- + +--EXPECT-- +string(5) "hello" +string(3) "Mid" +string(4) "Base" +string(5) "other" +string(5) "hello" +string(4) "Leaf" +string(5) "hello" +int(42) +string(5) "hello" diff --git a/tests/compiler/const/class-const-override.phpt b/tests/compiler/const/class-const-override.phpt new file mode 100644 index 00000000..5d9e4c97 --- /dev/null +++ b/tests/compiler/const/class-const-override.phpt @@ -0,0 +1,30 @@ +--TEST-- +class const override referencing another constant +--FILE-- + +--EXPECT-- +string(1) "A" +string(1) "B" +string(1) "B" +string(3) "bbb" diff --git a/tests/compiler/const/class-const-parent-cross-ns.phpt b/tests/compiler/const/class-const-parent-cross-ns.phpt new file mode 100644 index 00000000..f550fb66 --- /dev/null +++ b/tests/compiler/const/class-const-parent-cross-ns.phpt @@ -0,0 +1,43 @@ +--TEST-- +parent class constants resolve across namespaces +--FILE-- + +--EXPECT-- +string(12) "Library\Base" +string(19) "Application\Sibling" +string(12) "Library\Base" +string(4) "base"