fix(compiler): validate property default expressions

pull/24/head
韩天峰 1 month ago
parent 8c3cbd4dce
commit 82739d3d66
  1. 12
      phpunit/code/property-default-class-const-type.php
  2. 10
      phpunit/code/property-default-expression-type.php
  3. 10
      phpunit/code/property-default-false-for-true.php
  4. 10
      phpunit/code/property-default-true-for-false.php
  5. 32
      phpunit/src/ClassTest.php
  6. 79
      src/Preprocessor.php
  7. 10
      src/Translator.php
  8. 41
      src/gen_stub.php
  9. 53
      tests/compiler/object_property/default-expressions-inheritance.phpt

@ -0,0 +1,12 @@
<?php
class PropertyDefaultClassConstType
{
public int $value = self::DEFAULT_VALUE;
private const DEFAULT_VALUE = 'invalid';
}
function main(): void
{
}

@ -0,0 +1,10 @@
<?php
class PropertyDefaultExpressionType
{
public int $value = 1.5 + 2;
}
function main(): void
{
}

@ -0,0 +1,10 @@
<?php
class PropertyDefaultFalseForTrue
{
public true $value = false;
}
function main(): void
{
}

@ -0,0 +1,10 @@
<?php
class PropertyDefaultTrueForFalse
{
public false $value = true;
}
function main(): void
{
}

@ -232,4 +232,36 @@ class ClassTest extends \BaseTest
// 合法的默认值(含 int→float 协变、nullable、联合类型、mixed、常量)应通过检查。 // 合法的默认值(含 int→float 协变、nullable、联合类型、mixed、常量)应通过检查。
$this->compile('property-default-valid.php'); $this->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'
);
}
} }

@ -21,6 +21,7 @@ use TypePhp\Exception\SyntaxError;
use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\PropertyHookLowering;
use TypePhp\Transform\Visitor; use TypePhp\Transform\Visitor;
use PhpParser\Modifiers; use PhpParser\Modifiers;
use PhpParser\ConstExprEvaluator;
use PhpParser\Node; use PhpParser\Node;
use PhpParser\Node\IntersectionType; use PhpParser\Node\IntersectionType;
use PhpParser\Node\NullableType; use PhpParser\Node\NullableType;
@ -620,12 +621,20 @@ class Preprocessor extends CompilerBase
} }
$this->symbolDeclInFile[$fullClassNameLower] = $this->file; $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 = ''; $code = '';
foreach ($class->stmts as $v) { foreach ($class->stmts as $v) {
$type = $v->getType(); $type = $v->getType();
switch ($type) { switch ($type) {
case 'Stmt_ClassConst': case 'Stmt_ClassConst':
$this->parseClassConstDef($v);
break; break;
case 'Stmt_Property': case 'Stmt_Property':
$this->parseClassPropertyDef($v); $this->parseClassPropertyDef($v);
@ -968,11 +977,16 @@ class Preprocessor extends CompilerBase
/** /**
* Determine the PHP value type of a constant expression used as a default * 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 * value. Returns one of int/float/string/true/false/array/null, or null when
* type cannot be decided statically. * 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()) { switch ($node->getType()) {
case 'Scalar_Int': case 'Scalar_Int':
return 'int'; return 'int';
@ -986,15 +1000,62 @@ class Preprocessor extends CompilerBase
return 'array'; return 'array';
case 'Expr_UnaryMinus': case 'Expr_UnaryMinus':
case 'Expr_UnaryPlus': case 'Expr_UnaryPlus':
return $this->detectDefaultValueType($node->expr); return $this->detectDefaultValueType($node->expr, $scopeClass, $depth + 1);
case 'Expr_ConstFetch': case 'Expr_ConstFetch':
return match (strtolower($node->name->toString())) { return match (strtolower($node->name->toString())) {
'true', 'false' => 'bool', 'true' => 'true',
'false' => 'false',
'null' => 'null', 'null' => 'null',
default => 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: 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'], 'int' => ['int'],
'float', 'double' => ['float', 'int'], // int coerces to float 'float', 'double' => ['float', 'int'], // int coerces to float
'string' => ['string'], 'string' => ['string'],
'bool', 'true', 'false' => ['bool'], 'bool' => ['true', 'false'],
'true' => ['true'],
'false' => ['false'],
'array' => ['array'], 'array' => ['array'],
'iterable' => ['array'], 'iterable' => ['array'],
'null' => ['null'], 'null' => ['null'],

@ -887,10 +887,13 @@ CODE;
. $property->arrayInitPlan->expr . ');' . PHP_EOL; . $property->arrayInitPlan->expr . ');' . PHP_EOL;
$code .= $this->wrapArrayInitPlan($property->arrayInitPlan, $statement); $code .= $this->wrapArrayInitPlan($property->arrayInitPlan, $statement);
} else { } else {
$default = $property->type === Type::FLOAT
? $this->convertFloatExpr($property->default)
: $property->default;
$statement = 'php::setStaticProperty(' $statement = 'php::setStaticProperty('
. $this->genCharPtr($classDef->getNamespacedName(false), true) . ', ' . $this->genCharPtr($classDef->getNamespacedName(false), true) . ', '
. $this->genCharPtr($property->name) . ', ' . $this->genCharPtr($property->name) . ', '
. 'php::Var(' . $property->default . '));' . PHP_EOL; . 'php::Var(' . $default . '));' . PHP_EOL;
$code .= $statement; $code .= $statement;
} }
} }
@ -1643,8 +1646,11 @@ CODE;
// property table via zend_update_property. Each property is // property table via zend_update_property. Each property is
// wrapped in its own block so the local `value` does not // wrapped in its own block so the local `value` does not
// clash with siblings declared in the same create_object body. // 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 = "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 .= 'zend_update_property(obj->ce, obj, ' . $this->genZendStrl($property->name) . ", value.ptr());\n";
$init .= "php::throwErrorIfOccurred();\n"; $init .= "php::throwErrorIfOccurred();\n";
$init .= "} while (0);\n"; $init .= "} while (0);\n";

@ -2371,12 +2371,16 @@ class EvaluatedValue
if ($class === 'self') { if ($class === 'self') {
$constName = ClassInfo::$currentClass . "::" . $constName; $constName = ClassInfo::$currentClass . "::" . $constName;
if (isset($allConstInfos[$constName])) { if (isset($allConstInfos[$constName])) {
return formatConstValue($allConstInfos[$constName]->getValue($allConstInfos)->value); return $allConstInfos[$constName]->getValue($allConstInfos)->value;
} else { } else {
return formatConstValue(getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $constName)); return normalizeConstExprValue(
getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $constName)
);
} }
} else { } else {
return formatConstValue(getTranslator()->getClassConstValue($expr, $class, $constName, ClassInfo::$currentClass)); return normalizeConstExprValue(
getTranslator()->getClassConstValue($expr, $class, $constName, ClassInfo::$currentClass)
);
} }
} else { } else {
$constName = $expr->name->__toString(); $constName = $expr->name->__toString();
@ -2414,7 +2418,7 @@ class EvaluatedValue
if (isset($definedConstants[$constName])) { if (isset($definedConstants[$constName])) {
$constValue = $definedConstants[$constName]; $constValue = $definedConstants[$constName];
if (is_scalar($constValue)) { if (is_scalar($constValue)) {
return formatConstValue($constValue); return $constValue;
} }
} }
@ -2508,11 +2512,15 @@ class EvaluatedValue
// fully qualified class name (already stored in $this->value). // fully qualified class name (already stored in $this->value).
return '"' . addcslashes($this->value, '\\') . '"'; return '"' . addcslashes($this->value, '\\') . '"';
} }
return $this->value; return '"' . getTranslator()->escapeString((string) $this->value) . '"';
} elseif ($this->expr instanceof Expr\ConstFetch) { } elseif ($this->expr instanceof Expr\ConstFetch) {
return getTranslator()->getConstValue($this->expr->name->toString()); return getTranslator()->getConstValue($this->expr->name->toString());
} elseif (!($this->expr instanceof String_)) { } 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)); $expr = preg_replace("/(^'|'$)/", '"', getTranslator()->escapeString($expr));
} elseif ($this->type->isInt() or $this->type->isFloat()) { } elseif ($this->type->isInt() or $this->type->isFloat()) {
@ -3306,6 +3314,11 @@ class PropertyInfo extends VariableLike
$defaultValue = EvaluatedValue::null(); $defaultValue = EvaluatedValue::null();
} else { } else {
$defaultValue = EvaluatedValue::createFromExpression($this->defaultValue, null, null, $allConstInfos); $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)) { 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"; echo "Skipping code generation for property $this->name, because it has an unknown constant default value\n";
return ""; return "";
@ -6365,16 +6378,16 @@ function initPhpParser() {
$isInitialized = true; $isInitialized = true;
} }
function formatConstValue(mixed $constValue) function normalizeConstExprValue(mixed $constValue): mixed
{ {
if (is_string($constValue)) { if (is_string($constValue)
if (str_starts_with($constValue, '"') and str_ends_with($constValue, '"')) { && strlen($constValue) >= 2
return $constValue; && $constValue[0] === '"'
} && $constValue[strlen($constValue) - 1] === '"'
return '"' . $constValue . '"'; ) {
} else { return stripcslashes(substr($constValue, 1, -1));
return $constValue;
} }
return $constValue;
} }
function getTranslator(): Translator function getTranslator(): Translator

@ -0,0 +1,53 @@
--TEST--
Property defaults support constant expressions, inheritance, and traits
--FILE--
<?php
const GLOBAL_NUMBER = 4;
trait PropertyDefaultsTrait
{
public int $traitValue = 10 + 1;
public static string $traitStatic = 'trait' . '-static';
}
class PropertyDefaultsParent
{
private const LABEL_PREFIX = 'parent';
public int $sum = 1 + 2;
public float $ratio = 2;
protected string $label = self::LABEL_PREFIX . '-value';
public static int $counter = GLOBAL_NUMBER + 1;
public static float $staticRatio = 3;
public function label(): string
{
return $this->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"
Loading…
Cancel
Save