From 82739d3d66f4970ae448efa23d0fa3cf50737d14 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 16:05:00 +0800 Subject: [PATCH] fix(compiler): validate property default expressions --- .../property-default-class-const-type.php | 12 +++ .../code/property-default-expression-type.php | 10 +++ .../code/property-default-false-for-true.php | 10 +++ .../code/property-default-true-for-false.php | 10 +++ phpunit/src/ClassTest.php | 32 ++++++++ src/Preprocessor.php | 79 +++++++++++++++++-- src/Translator.php | 10 ++- src/gen_stub.php | 41 ++++++---- .../default-expressions-inheritance.phpt | 53 +++++++++++++ 9 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 phpunit/code/property-default-class-const-type.php create mode 100644 phpunit/code/property-default-expression-type.php create mode 100644 phpunit/code/property-default-false-for-true.php create mode 100644 phpunit/code/property-default-true-for-false.php create mode 100644 tests/compiler/object_property/default-expressions-inheritance.phpt diff --git a/phpunit/code/property-default-class-const-type.php b/phpunit/code/property-default-class-const-type.php new file mode 100644 index 00000000..fb5789be --- /dev/null +++ b/phpunit/code/property-default-class-const-type.php @@ -0,0 +1,12 @@ +compile('property-default-valid.php'); } + + public function testTrueDefaultForFalsePropertyFailsAtCompileTime() + { + $this->exec( + 'Cannot use true as default value for property PropertyDefaultTrueForFalse::$value of type false', + 'property-default-true-for-false.php' + ); + } + + public function testFalseDefaultForTruePropertyFailsAtCompileTime() + { + $this->exec( + 'Cannot use false as default value for property PropertyDefaultFalseForTrue::$value of type true', + 'property-default-false-for-true.php' + ); + } + + public function testClassConstantPropertyDefaultTypeFailsAtCompileTime() + { + $this->exec( + 'Cannot use string as default value for property PropertyDefaultClassConstType::$value of type int', + 'property-default-class-const-type.php' + ); + } + + public function testExpressionPropertyDefaultTypeFailsAtCompileTime() + { + $this->exec( + 'Cannot use float as default value for property PropertyDefaultExpressionType::$value of type int', + 'property-default-expression-type.php' + ); + } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 80d9249b..612906d6 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -21,6 +21,7 @@ use TypePhp\Exception\SyntaxError; use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\Visitor; use PhpParser\Modifiers; +use PhpParser\ConstExprEvaluator; use PhpParser\Node; use PhpParser\Node\IntersectionType; use PhpParser\Node\NullableType; @@ -620,12 +621,20 @@ class Preprocessor extends CompilerBase } $this->symbolDeclInFile[$fullClassNameLower] = $this->file; + // Property defaults may reference class constants declared later in the + // class body. Collect every constant first so default-value validation + // is independent of declaration order, matching PHP's class semantics. + foreach ($class->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\ClassConst) { + $this->parseClassConstDef($stmt); + } + } + $code = ''; foreach ($class->stmts as $v) { $type = $v->getType(); switch ($type) { case 'Stmt_ClassConst': - $this->parseClassConstDef($v); break; case 'Stmt_Property': $this->parseClassPropertyDef($v); @@ -968,11 +977,16 @@ class Preprocessor extends CompilerBase /** * Determine the PHP value type of a constant expression used as a default - * value. Returns one of int/float/string/bool/array/null, or null when the - * type cannot be decided statically. + * value. Returns one of int/float/string/true/false/array/null, or null when + * the type cannot be decided statically. */ - protected function detectDefaultValueType(NodeAbstract $node): ?string + protected function detectDefaultValueType(NodeAbstract $node, ?string $scopeClass = null, int $depth = 0): ?string { + if ($depth > 16) { + return null; + } + $scopeClass ??= $this->getFullClassName(); + switch ($node->getType()) { case 'Scalar_Int': return 'int'; @@ -986,15 +1000,62 @@ class Preprocessor extends CompilerBase return 'array'; case 'Expr_UnaryMinus': case 'Expr_UnaryPlus': - return $this->detectDefaultValueType($node->expr); + return $this->detectDefaultValueType($node->expr, $scopeClass, $depth + 1); case 'Expr_ConstFetch': return match (strtolower($node->name->toString())) { - 'true', 'false' => 'bool', + 'true' => 'true', + 'false' => 'false', 'null' => 'null', default => null, }; + case 'Expr_ClassConstFetch': + if (!$node->class instanceof Node\Name || !$node->name instanceof Node\Identifier) { + return null; + } + $constName = $node->name->toString(); + if (strcasecmp($constName, 'class') === 0) { + return 'string'; + } + $className = $node->class->toString(); + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + $targetClass = $scopeClass; + } elseif (strcasecmp($className, 'parent') === 0) { + $targetClass = $this->getParentClass($scopeClass); + } else { + $targetClass = $this->getNamespacedClassName($className); + } + if ($targetClass === '' || !$this->hasClass($targetClass)) { + return null; + } + $targetDef = $this->getClass($targetClass); + if (!$targetDef->hasConstant($constName)) { + return null; + } + return $this->detectDefaultValueType( + $targetDef->getConstant($constName)->valueExpr, + $targetClass, + $depth + 1 + ); default: - return null; + try { + $value = (new ConstExprEvaluator( + static function (Node\Expr $expr): never { + throw new \RuntimeException('Unresolved constant expression'); + } + ))->evaluateDirectly($node); + } catch (\Throwable) { + return null; + } + return match (true) { + is_int($value) => 'int', + is_float($value) => 'float', + is_string($value) => 'string', + $value === true => 'true', + $value === false => 'false', + is_array($value) => 'array', + $value === null => 'null', + default => null, + }; } } @@ -1037,7 +1098,9 @@ class Preprocessor extends CompilerBase 'int' => ['int'], 'float', 'double' => ['float', 'int'], // int coerces to float 'string' => ['string'], - 'bool', 'true', 'false' => ['bool'], + 'bool' => ['true', 'false'], + 'true' => ['true'], + 'false' => ['false'], 'array' => ['array'], 'iterable' => ['array'], 'null' => ['null'], diff --git a/src/Translator.php b/src/Translator.php index 7422ee36..3b1950de 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -887,10 +887,13 @@ CODE; . $property->arrayInitPlan->expr . ');' . PHP_EOL; $code .= $this->wrapArrayInitPlan($property->arrayInitPlan, $statement); } else { + $default = $property->type === Type::FLOAT + ? $this->convertFloatExpr($property->default) + : $property->default; $statement = 'php::setStaticProperty(' . $this->genCharPtr($classDef->getNamespacedName(false), true) . ', ' . $this->genCharPtr($property->name) . ', ' - . 'php::Var(' . $property->default . '));' . PHP_EOL; + . 'php::Var(' . $default . '));' . PHP_EOL; $code .= $statement; } } @@ -1643,8 +1646,11 @@ CODE; // property table via zend_update_property. Each property is // wrapped in its own block so the local `value` does not // clash with siblings declared in the same create_object body. + $default = $property->type === Type::FLOAT + ? $this->convertFloatExpr($property->default) + : $property->default; $init = "do {\n"; - $init .= "auto value = php::Var({$property->default});\n"; + $init .= "auto value = php::Var({$default});\n"; $init .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n"; $init .= "php::throwErrorIfOccurred();\n"; $init .= "} while (0);\n"; diff --git a/src/gen_stub.php b/src/gen_stub.php index 752c6b9b..c19ee29a 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2371,12 +2371,16 @@ class EvaluatedValue if ($class === 'self') { $constName = ClassInfo::$currentClass . "::" . $constName; if (isset($allConstInfos[$constName])) { - return formatConstValue($allConstInfos[$constName]->getValue($allConstInfos)->value); + return $allConstInfos[$constName]->getValue($allConstInfos)->value; } else { - return formatConstValue(getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $constName)); + return normalizeConstExprValue( + getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $constName) + ); } } else { - return formatConstValue(getTranslator()->getClassConstValue($expr, $class, $constName, ClassInfo::$currentClass)); + return normalizeConstExprValue( + getTranslator()->getClassConstValue($expr, $class, $constName, ClassInfo::$currentClass) + ); } } else { $constName = $expr->name->__toString(); @@ -2414,7 +2418,7 @@ class EvaluatedValue if (isset($definedConstants[$constName])) { $constValue = $definedConstants[$constName]; if (is_scalar($constValue)) { - return formatConstValue($constValue); + return $constValue; } } @@ -2508,11 +2512,15 @@ class EvaluatedValue // fully qualified class name (already stored in $this->value). return '"' . addcslashes($this->value, '\\') . '"'; } - return $this->value; + return '"' . getTranslator()->escapeString((string) $this->value) . '"'; } elseif ($this->expr instanceof Expr\ConstFetch) { return getTranslator()->getConstValue($this->expr->name->toString()); } elseif (!($this->expr instanceof String_)) { - throw new Exception("Expression at line " . $this->expr->getStartLine() . " must be a scalar string"); + // ConstExprEvaluator has already reduced concatenations and + // other constant string expressions to their PHP value. Emit + // that value as a C string literal instead of rejecting every + // non-literal string expression. + return '"' . getTranslator()->escapeString((string) $this->value) . '"'; } $expr = preg_replace("/(^'|'$)/", '"', getTranslator()->escapeString($expr)); } elseif ($this->type->isInt() or $this->type->isFloat()) { @@ -3306,6 +3314,11 @@ class PropertyInfo extends VariableLike $defaultValue = EvaluatedValue::null(); } else { $defaultValue = EvaluatedValue::createFromExpression($this->defaultValue, null, null, $allConstInfos); + if ($simpleType !== null && $simpleType->isFloat() && $defaultValue->type->isInt()) { + // PHP permits an integer default for a float property and + // stores it as a double in the class default table. + $defaultValue->type = $simpleType; + } if ($defaultValue->isUnknownConstValue || ($defaultValue->originatingConsts && $defaultValue->getCExpr() === null)) { echo "Skipping code generation for property $this->name, because it has an unknown constant default value\n"; return ""; @@ -6365,16 +6378,16 @@ function initPhpParser() { $isInitialized = true; } -function formatConstValue(mixed $constValue) +function normalizeConstExprValue(mixed $constValue): mixed { - if (is_string($constValue)) { - if (str_starts_with($constValue, '"') and str_ends_with($constValue, '"')) { - return $constValue; - } - return '"' . $constValue . '"'; - } else { - return $constValue; + if (is_string($constValue) + && strlen($constValue) >= 2 + && $constValue[0] === '"' + && $constValue[strlen($constValue) - 1] === '"' + ) { + return stripcslashes(substr($constValue, 1, -1)); } + return $constValue; } function getTranslator(): Translator diff --git a/tests/compiler/object_property/default-expressions-inheritance.phpt b/tests/compiler/object_property/default-expressions-inheritance.phpt new file mode 100644 index 00000000..fc517d24 --- /dev/null +++ b/tests/compiler/object_property/default-expressions-inheritance.phpt @@ -0,0 +1,53 @@ +--TEST-- +Property defaults support constant expressions, inheritance, and traits +--FILE-- +label; + } +} + +class PropertyDefaultsChild extends PropertyDefaultsParent +{ + use PropertyDefaultsTrait; +} + +function main(): void +{ + $value = new PropertyDefaultsChild(); + var_dump($value->sum, $value->ratio, $value->label(), $value->traitValue); + var_dump( + PropertyDefaultsChild::$counter, + PropertyDefaultsChild::$staticRatio, + PropertyDefaultsChild::$traitStatic + ); +} +?> +--EXPECT-- +int(3) +float(2) +string(12) "parent-value" +int(11) +int(5) +float(3) +string(12) "trait-static"