From a24310fb669b1de5f264807d632a89eaf80586c0 Mon Sep 17 00:00:00 2001 From: yavon007 Date: Tue, 22 Sep 2026 16:43:08 +0800 Subject: [PATCH 1/4] fix(compiler): apply unary plus numeric conversion --- phpunit/code/unary-plus-codegen.php | 11 ++++ phpunit/src/UnaryPlusCodegenTest.php | 24 ++++++++ src/CompilerBase.php | 12 +++- src/Parser/UnaryExpressionTrait.php | 57 +++++++++++++++++- src/Preprocessor.php | 1 - .../operator/unary-plus-conversion.phpt | 58 +++++++++++++++++++ .../operator/unary-plus-defaults.phpt | 48 +++++++++++++++ 7 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 phpunit/code/unary-plus-codegen.php create mode 100644 phpunit/src/UnaryPlusCodegenTest.php create mode 100644 tests/compiler/operator/unary-plus-conversion.phpt create mode 100644 tests/compiler/operator/unary-plus-defaults.phpt diff --git a/phpunit/code/unary-plus-codegen.php b/phpunit/code/unary-plus-codegen.php new file mode 100644 index 00000000..f9d89902 --- /dev/null +++ b/phpunit/code/unary-plus-codegen.php @@ -0,0 +1,11 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + self::assertStringNotContainsString('php::Variant(number)', $code); + self::assertStringContainsString('php::toInt(number)', $code); + self::assertStringContainsString('php::toFloat(number)', $code); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index a9f95fd1..79aef34b 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3369,8 +3369,18 @@ protected function detectTypeOfExpr($expr): string $exprType = $expr->getType(); switch ($exprType) { - case 'Expr_UnaryMinus': case 'Expr_UnaryPlus': + $constant = $this->constantUnaryPlusValue($expr); + if ($constant !== null) { + return is_float($constant) ? Type::FLOAT : Type::INT; + } + $innerType = $this->unaryPlusOperandType($expr->expr); + return match ($innerType) { + Type::BOOL => Type::INT, + Type::INT, Type::FLOAT, Type::BIGINT, Type::BIGFLOAT, Type::DECIMAL => $innerType, + default => Type::VAR, + }; + case 'Expr_UnaryMinus': $innerType = $this->detectTypeOfExpr($expr->expr); if ( $this->varIntTypes diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index 6fbe1f62..64a20662 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -14,6 +14,45 @@ trait UnaryExpressionTrait { + protected function unaryPlusOperandType(Expr $operand): string + { + if ($operand instanceof Expr\ErrorSuppress || $operand instanceof Expr\Assign) { + return $this->unaryPlusOperandType($operand->expr); + } + if ($operand instanceof Expr\Ternary) { + $ifType = $this->unaryPlusOperandType($operand->if ?? $operand->cond); + $elseType = $this->unaryPlusOperandType($operand->else); + return $ifType === $elseType ? $ifType : Type::VAR; + } + $type = $this->detectTypeOfExpr($operand); + if ($operand instanceof Expr\PropertyFetch && $this->isIdExpr($operand->name)) { + $class = $this->resolveObjectClassDef($operand->var); + $name = $operand->name->toString(); + if ($class !== null && $class->hasProperty($name) && $class->getProperty($name)->nullable) { + return Type::VAR; + } + } + if ($operand instanceof Expr\StaticPropertyFetch && $this->getNativePropertyDef($operand)?->nullable) { + return Type::VAR; + } + return $type; + } + + protected function constantUnaryPlusValue(Expr\UnaryPlus $expr): int|float|null + { + if ($expr->expr instanceof \PhpParser\Node\Scalar\String_ && is_numeric($expr->expr->value)) { + return +$expr->expr->value; + } + if ($expr->expr instanceof Expr\ConstFetch) { + return match (strtolower($expr->expr->name->toString())) { + 'true' => 1, + 'false', 'null' => 0, + default => null, + }; + } + return null; + } + protected function parseCastVoid(Expr\Cast\Void_ $node): string { if (!$node->getAttribute(VoidCastValidationVisitor::ALLOWED_ATTRIBUTE, false)) { @@ -148,6 +187,22 @@ protected function parseUnaryPlus(Expr\UnaryPlus $expr): string } $this->assertNativeObjectOperatorOperandSupported($expr->expr, $expr, '+', true); $this->assertExprCanBeUsedAsValue($expr->expr, 'unary operand'); - return $this->parseExprAsValue($expr->expr); + $type = $this->unaryPlusOperandType($expr->expr); + $constant = $this->constantUnaryPlusValue($expr); + if ($constant !== null) { + return is_float($constant) + ? $this->genFloatLiteral($constant) + : $this->genIntegerLiteral($constant); + } + $code = $this->parseExprAsValue($expr->expr); + if ($type === Type::BOOL) { + return $this->convertIntExpr($code); + } + if (in_array($type, [Type::INT, Type::FLOAT, Type::BIGINT, Type::BIGFLOAT, Type::DECIMAL], true)) { + return $code; + } + // Zend lowers unary plus to multiplication by one, preserving numeric + // conversion, warnings, TypeError, and the sign of floating-point zero. + return '(php::Variant(' . $code . ') * 1)'; } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 5241d60d..cfafe630 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -2594,7 +2594,6 @@ protected function detectDefaultValueType(NodeAbstract $node, ?string $scopeClas case 'Expr_Array': return 'array'; case 'Expr_UnaryMinus': - case 'Expr_UnaryPlus': return $this->detectDefaultValueType($node->expr, $scopeClass, $depth + 1); case 'Expr_ConstFetch': return match (strtolower($node->name->toString())) { diff --git a/tests/compiler/operator/unary-plus-conversion.phpt b/tests/compiler/operator/unary-plus-conversion.phpt new file mode 100644 index 00000000..9536236f --- /dev/null +++ b/tests/compiler/operator/unary-plus-conversion.phpt @@ -0,0 +1,58 @@ +--TEST-- +Unary plus preserves PHP numeric conversion, errors and evaluation count +--FILE-- +getMessage(), "\n"; } + } + set_error_handler(function ($level, $message, $file, $line) { echo $message, "\n"; return true; }); + var_dump(numericText('12tail')); + restore_error_handler(); + $calls = 0; + var_dump(+once($calls), $calls); + $source = '8'; + $alias =& $source; + $result = +$alias; + var_dump($result, $source, $alias); +} +?> +--EXPECT-- +int(42) +float(1.5) +int(1) +float(1.5) +int(24) +int(0) +int(12) +float(1.25) +float(100) +float(9.223372036854776E+18) +int(1) +int(0) +int(0) +int(7) +float(-0) +Unsupported operand types: string * int +Unsupported operand types: array * int +Unsupported operand types: stdClass * int +A non-numeric value encountered +int(12) +float(2.5) +int(1) +int(8) +string(1) "8" +string(1) "8" diff --git a/tests/compiler/operator/unary-plus-defaults.phpt b/tests/compiler/operator/unary-plus-defaults.phpt new file mode 100644 index 00000000..acf14065 --- /dev/null +++ b/tests/compiler/operator/unary-plus-defaults.phpt @@ -0,0 +1,48 @@ +--TEST-- +Unary plus property and parameter defaults use the converted numeric type +--FILE-- +text, $value->truth, $value->nothing, $value->decimal); + var_dump(PositiveDefaults::VALUE, defaultNumber()); + var_dump($value->minimum === PHP_INT_MIN); + var_dump(+$value->flag); + $value->flag = true; + var_dump(+$value->flag); + var_dump(+$value->optionalInt, +$value->optionalFloat, +PositiveDefaults::$optionalStatic); + var_dump(+(@$value->optionalFloat)); + var_dump(+($value->flag ? $value->optionalFloat : PositiveDefaults::$optionalStatic)); + $value->optionalFloat = 1.5; + var_dump(+$value->optionalFloat); +} +?> +--EXPECT-- +int(12) +int(1) +int(0) +float(1.5) +int(24) +int(6) +bool(true) +int(0) +int(1) +int(0) +int(0) +int(0) +int(0) +int(0) +float(1.5) From 6ab22277d5576494a2bf8e9b7d00761bb93c565c Mon Sep 17 00:00:00 2001 From: yavon007 Date: Tue, 22 Sep 2026 18:06:25 +0800 Subject: [PATCH 2/4] fix(compiler): infer unary plus assignments from target type --- src/Parser/UnaryExpressionTrait.php | 7 +++- .../operator/unary-plus-assignment.phpt | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/compiler/operator/unary-plus-assignment.phpt diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index 64a20662..b22c2f02 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -16,9 +16,14 @@ trait UnaryExpressionTrait { protected function unaryPlusOperandType(Expr $operand): string { - if ($operand instanceof Expr\ErrorSuppress || $operand instanceof Expr\Assign) { + if ($operand instanceof Expr\ErrorSuppress) { return $this->unaryPlusOperandType($operand->expr); } + if ($operand instanceof Expr\Assign) { + // Assignment evaluates to the value after conversion to its target + // type, which can differ from the RHS (e.g. int to float). + return $this->unaryPlusOperandType($operand->var); + } if ($operand instanceof Expr\Ternary) { $ifType = $this->unaryPlusOperandType($operand->if ?? $operand->cond); $elseType = $this->unaryPlusOperandType($operand->else); diff --git a/tests/compiler/operator/unary-plus-assignment.phpt b/tests/compiler/operator/unary-plus-assignment.phpt new file mode 100644 index 00000000..7cca0e1a --- /dev/null +++ b/tests/compiler/operator/unary-plus-assignment.phpt @@ -0,0 +1,37 @@ +--TEST-- +Unary plus uses the converted assignment result for typed properties +--FILE-- +number = 2), $box->number); + var_dump(+(UnaryAssignmentBox::$shared = 3), UnaryAssignmentBox::$shared); + var_dump(+($box->optional = 4), +($box->optional = null)); + var_dump(+(UnaryAssignmentBox::$nullableShared = 5), +(UnaryAssignmentBox::$nullableShared = null)); + var_dump(+@($box->number = 6)); + $calls = 0; + var_dump(+($box->number = assignedOnce($calls)), $calls); + var_dump(+($local = 7), $local); +} +?> +--EXPECT-- +float(2) +float(2) +float(3) +float(3) +float(4) +int(0) +float(5) +int(0) +float(6) +float(2) +int(1) +int(7) +int(7) From 1443e87b249acd9ee8163e7b541d83d88277f60f Mon Sep 17 00:00:00 2001 From: yavon007 Date: Tue, 22 Sep 2026 18:09:21 +0800 Subject: [PATCH 3/4] fix(compiler): retain unary plus inference for fresh locals --- src/Parser/UnaryExpressionTrait.php | 6 ++++++ .../compiler/operator/unary-plus-new-local.phpt | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/compiler/operator/unary-plus-new-local.phpt diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index b22c2f02..93afbc61 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -20,6 +20,12 @@ protected function unaryPlusOperandType(Expr $operand): string return $this->unaryPlusOperandType($operand->expr); } if ($operand instanceof Expr\Assign) { + if ($operand->var instanceof Expr\Variable + && is_string($operand->var->name) + && !$this->hasVar($operand->var->name)) { + // A fresh local has no target type until assignment registers it. + return $this->unaryPlusOperandType($operand->expr); + } // Assignment evaluates to the value after conversion to its target // type, which can differ from the RHS (e.g. int to float). return $this->unaryPlusOperandType($operand->var); diff --git a/tests/compiler/operator/unary-plus-new-local.phpt b/tests/compiler/operator/unary-plus-new-local.phpt new file mode 100644 index 00000000..31b4e8da --- /dev/null +++ b/tests/compiler/operator/unary-plus-new-local.phpt @@ -0,0 +1,17 @@ +--TEST-- +Unary plus preserves arbitrary precision values assigned to fresh locals +--FILE-- +toString(), "\n"; + echo (+($floating = std::bigFloat(2.5)))->toString(), "\n"; + echo (+($decimal = std::decimal('2.5')))->toString(), "\n"; + var_dump(+($native = 7), +($fraction = 1.5)); +} +?> +--EXPECT-- +2 +2.5 +2.5 +int(7) +float(1.5) From 0a587a6d10e7cd5f1d6e35ff41ead44d4bc104f3 Mon Sep 17 00:00:00 2001 From: yavon007 Date: Tue, 22 Sep 2026 18:10:58 +0800 Subject: [PATCH 4/4] fix(compiler): resolve existing unary assignment locals by compiled name --- src/Parser/UnaryExpressionTrait.php | 2 +- tests/compiler/operator/unary-plus-assignment.phpt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Parser/UnaryExpressionTrait.php b/src/Parser/UnaryExpressionTrait.php index 93afbc61..07e6a1ef 100644 --- a/src/Parser/UnaryExpressionTrait.php +++ b/src/Parser/UnaryExpressionTrait.php @@ -22,7 +22,7 @@ protected function unaryPlusOperandType(Expr $operand): string if ($operand instanceof Expr\Assign) { if ($operand->var instanceof Expr\Variable && is_string($operand->var->name) - && !$this->hasVar($operand->var->name)) { + && !$this->hasVar($this->parseVariable($operand->var))) { // A fresh local has no target type until assignment registers it. return $this->unaryPlusOperandType($operand->expr); } diff --git a/tests/compiler/operator/unary-plus-assignment.phpt b/tests/compiler/operator/unary-plus-assignment.phpt index 7cca0e1a..abb4a90c 100644 --- a/tests/compiler/operator/unary-plus-assignment.phpt +++ b/tests/compiler/operator/unary-plus-assignment.phpt @@ -9,6 +9,7 @@ class UnaryAssignmentBox { public static ?float $nullableShared = null; } function assignedOnce(int &$calls): int { ++$calls; return 2; } +function existingTarget(float $int): int|float { return +($int = 2); } function main() { $box = new UnaryAssignmentBox(); var_dump(+($box->number = 2), $box->number); @@ -19,6 +20,7 @@ function main() { $calls = 0; var_dump(+($box->number = assignedOnce($calls)), $calls); var_dump(+($local = 7), $local); + var_dump(existingTarget(0.0)); } ?> --EXPECT-- @@ -35,3 +37,4 @@ float(2) int(1) int(7) int(7) +float(2)