From 949715882ec4b7d0af9b711c106e6367041551d9 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Thu, 15 Oct 2026 16:44:24 +0545 Subject: [PATCH 1/2] fix(compiler): infer static return type for uniform match expressions Infer the static return type of match expressions when all non-throwing arms evaluate to the same uniform type (e.g., int, float, bool, string). Previously, Expr\Match_ was absent from CompilerBase::detectTypeOfExpr(), causing all match expressions to default to Type::VAR (php::Var). This forced redundant runtime return type checks (php::throwReturnTypeError), unnecessary unboxing (php::toInt, etc.), and loose php::Var variable allocation when returning or assigning match results. This change: 1. Adds Expr_Match handling in detectTypeOfExpr() by collecting and unifying the types of all non-throwing arms. 2. In parseMatch(), sets the lambda return type to the inferred assign type and tags the expression with ATTR_LOWERED_TYPE. 3. In formatMatchReturn(), handles Expr\Throw_ arms cleanly and allocates temporaries matching the inferred return type. 4. Adds regression tests in MatchExpressionCodegenTest. --- phpunit/code/match-expression-codegen.php | 59 +++++++++++ phpunit/src/MatchExpressionCodegenTest.php | 112 +++++++++++++++++++++ src/CompilerBase.php | 19 ++++ src/Parser/SelectionExpressionTrait.php | 46 +++++++-- 4 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 phpunit/code/match-expression-codegen.php create mode 100644 phpunit/src/MatchExpressionCodegenTest.php diff --git a/phpunit/code/match-expression-codegen.php b/phpunit/code/match-expression-codegen.php new file mode 100644 index 00000000..45933dd7 --- /dev/null +++ b/phpunit/code/match-expression-codegen.php @@ -0,0 +1,59 @@ + 10, + 2 => 20, + default => 30, + }; +} + +function testUniformStringMatch(int $x): string +{ + return match ($x) { + 1 => 'first', + 2 => 'second', + default => 'other', + }; +} + +function testMatchWithThrow(int $x): int +{ + return match ($x) { + 1 => 10, + 2 => 20, + default => throw new \RuntimeException('Invalid choice'), + }; +} + +function testMatchNoDefault(int $x): int +{ + return match ($x) { + 1 => 100, + 2 => 200, + }; +} + +function testMixedTypeMatch(int $x): mixed +{ + return match ($x) { + 1 => 10, + default => 'other', + }; +} + +function testMatchLocalVariableAssignment(int $x): void +{ + $result = match ($x) { + 1 => 10, + default => 20, + }; + var_dump($result); +} diff --git a/phpunit/src/MatchExpressionCodegenTest.php b/phpunit/src/MatchExpressionCodegenTest.php new file mode 100644 index 00000000..4e71d930 --- /dev/null +++ b/phpunit/src/MatchExpressionCodegenTest.php @@ -0,0 +1,112 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + self::assertIsString($code); + $this->generatedCode = $code; + } + + public function testUniformIntMatchInfersIntAndAvoidsRuntimeReturnTypeCheck(): void + { + // Must emit typed lambda returning php::Int + self::assertStringContainsString('php_testuniformintmatch', $this->generatedCode); + self::assertStringContainsString('[&]() -> php::Int {', $this->generatedCode); + + // Function body must not use php::Var temporary or throwReturnTypeError for uniform int match + $fnCode = $this->extractFunction('php_testuniformintmatch'); + self::assertStringNotContainsString('php::Var', $fnCode); + self::assertStringNotContainsString('php::throwReturnTypeError', $fnCode); + self::assertStringContainsString('return 10LL;', $fnCode); + self::assertStringContainsString('return 20LL;', $fnCode); + self::assertStringContainsString('return 30LL;', $fnCode); + } + + public function testUniformStringMatchInfersString(): void + { + self::assertStringContainsString('php_testuniformstringmatch', $this->generatedCode); + $fnCode = $this->extractFunction('php_testuniformstringmatch'); + self::assertStringContainsString('[&]() -> php::Str {', $fnCode); + self::assertStringNotContainsString('php::Var', $fnCode); + self::assertStringNotContainsString('php::throwReturnTypeError', $fnCode); + } + + public function testMatchWithThrowArmPreservesInferredReturnType(): void + { + $fnCode = $this->extractFunction('php_testmatchwiththrow'); + self::assertStringContainsString('[&]() -> php::Int {', $fnCode); + self::assertStringContainsString('php::throwException(', $fnCode); + self::assertStringContainsString('return {};', $fnCode); + } + + public function testMatchNoDefaultEmitsTypedUnhandledMatchCase(): void + { + $fnCode = $this->extractFunction('php_testmatchnodefault'); + self::assertStringContainsString('[&]() -> php::Int {', $fnCode); + self::assertStringContainsString('php::throwException("UnhandledMatchError", "Unhandled match case");', $fnCode); + self::assertStringContainsString('return {};', $fnCode); + } + + public function testMixedTypeMatchFallsBackToVariant(): void + { + $fnCode = $this->extractFunction('php_testmixedtypematch'); + self::assertStringContainsString('[&]() -> php::Var {', $fnCode); + self::assertStringContainsString('php::Var tmp_var_', $fnCode); + } + + public function testMatchExpressionLocalVariableAssignment(): void + { + $fnCode = $this->extractFunction('php_testmatchlocalvariableassignment'); + // $result must be inferred as php::Int rather than php::Var + self::assertStringContainsString('php::Int result = 0;', $fnCode); + self::assertStringNotContainsString('php::Var result;', $fnCode); + } + + private function extractFunction(string $name): string + { + $pos = strpos($this->generatedCode, $name); + self::assertNotFalse($pos, "Function {$name} not found in generated code"); + $braceStart = strpos($this->generatedCode, '{', $pos); + self::assertNotFalse($braceStart); + + $depth = 0; + $len = strlen($this->generatedCode); + for ($i = $braceStart; $i < $len; $i++) { + $char = $this->generatedCode[$i]; + if ($char === '{') { + $depth++; + } elseif ($char === '}') { + $depth--; + if ($depth === 0) { + return substr($this->generatedCode, $pos, $i - $pos + 1); + } + } + } + + return substr($this->generatedCode, $pos, 1000); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index a7d46b9a..cdeb26e3 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3551,6 +3551,25 @@ protected function detectTypeOfExpr($expr): string : $this->detectTypeOfExpr($expr->if); $elseType = $this->detectTypeOfExpr($expr->else); return $ifType === $elseType ? $ifType : Type::VAR; + case 'Expr_Match': + if (empty($expr->arms)) { + return Type::VAR; + } + $armTypes = []; + foreach ($expr->arms as $arm) { + if ($arm->body instanceof Expr\Throw_) { + continue; + } + $armTypes[] = Type::getReferencedType($this->detectTypeOfExpr($arm->body)); + } + if (empty($armTypes)) { + return Type::VAR; + } + $uniqueTypes = array_unique($armTypes); + if (count($uniqueTypes) === 1) { + return reset($uniqueTypes); + } + return Type::VAR; case 'Expr_BinaryOp_Plus': case 'Expr_BinaryOp_Minus': case 'Expr_BinaryOp_Mul': diff --git a/src/Parser/SelectionExpressionTrait.php b/src/Parser/SelectionExpressionTrait.php index 38a3eaab..5537f19f 100644 --- a/src/Parser/SelectionExpressionTrait.php +++ b/src/Parser/SelectionExpressionTrait.php @@ -143,9 +143,13 @@ protected function parseMatch(Expr\Match_ $expr): string $nativeSelection = $this->isNativeObjectClass($nativeClass); $conditionNativeClass = $this->detectClassOfExpr($expr->cond); $nativeCondition = $this->isNativeObjectClass($conditionNativeClass); - $returnType = $nativeSelection - ? $this->getNativeObjectPointerType($nativeClass) - : Type::VAR; + if ($nativeSelection) { + $returnType = $this->getNativeObjectPointerType($nativeClass); + } else { + $inferredType = $this->detectTypeOfExpr($expr); + $returnType = $this->getNormalAssignType($inferredType); + } + $expr->setAttribute(self::ATTR_LOWERED_TYPE, $returnType); $this->assertExprCanBeUsedAsValue($expr->cond, 'match condition'); $var = $this->parseIdentifier($expr->cond); if ($this->isVarExpr($expr->cond)) { @@ -220,7 +224,7 @@ protected function parseMatch(Expr\Match_ $expr): string } $code .= $this->getIndent() . 'if (' . $matched . ') {' . PHP_EOL; $this->indentLevel++; - $code .= $this->formatMatchReturn($arm->body, $nativeClass); + $code .= $this->formatMatchReturn($arm->body, $nativeClass, $returnType); $this->indentLevel--; $code .= $this->getIndent() . '}' . PHP_EOL; } @@ -228,16 +232,21 @@ protected function parseMatch(Expr\Match_ $expr): string if ($default) { $code .= $this->getIndent() . '{' . PHP_EOL; $this->indentLevel++; - $code .= $this->formatMatchReturn($default, $nativeClass); + $code .= $this->formatMatchReturn($default, $nativeClass, $returnType); $this->indentLevel--; $code .= $this->getIndent() . '}' . PHP_EOL; } else { $code .= $this->getIndent() . '{' . PHP_EOL; $this->indentLevel++; - $code .= $nativeSelection - ? $this->getIndent() . 'php::throwException("UnhandledMatchError", "Unhandled match case");' . PHP_EOL - . $this->getIndent() . 'return nullptr;' . PHP_EOL - : $this->getIndent() . 'return php::throwException("UnhandledMatchError", "Unhandled match case");' . PHP_EOL; + if ($nativeSelection) { + $code .= $this->getIndent() . 'php::throwException("UnhandledMatchError", "Unhandled match case");' . PHP_EOL + . $this->getIndent() . 'return nullptr;' . PHP_EOL; + } elseif ($returnType === Type::VAR) { + $code .= $this->getIndent() . 'return php::throwException("UnhandledMatchError", "Unhandled match case");' . PHP_EOL; + } else { + $code .= $this->getIndent() . 'php::throwException("UnhandledMatchError", "Unhandled match case");' . PHP_EOL + . $this->getIndent() . 'return {};' . PHP_EOL; + } $this->indentLevel--; $code .= $this->getIndent() . '}' . PHP_EOL; } @@ -247,9 +256,24 @@ protected function parseMatch(Expr\Match_ $expr): string return $code; } - protected function formatMatchReturn(NodeAbstract $body, string $nativeClass = ''): string + protected function formatMatchReturn(NodeAbstract $body, string $nativeClass = '', string $returnType = Type::VAR): string { $this->assertExprCanBeUsedAsValue($body, 'match arm'); + if ($body instanceof Expr\Throw_) { + [$value, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($body); + $code = $this->formatCapturedStmtLines($beforeStmts); + $code .= $this->formatCapturedStmtLines($afterStmts); + if ($nativeClass !== '') { + $code .= $this->getIndent() . $value . ';' . PHP_EOL; + $code .= $this->getIndent() . 'return nullptr;' . PHP_EOL; + } elseif ($returnType === Type::VAR) { + $code .= $this->getIndent() . 'return ' . $value . ';' . PHP_EOL; + } else { + $code .= $this->getIndent() . $value . ';' . PHP_EOL; + $code .= $this->getIndent() . 'return {};' . PHP_EOL; + } + return $code; + } [$value, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($body); if ($nativeClass !== '' && $this->isNull($body)) { $value = 'nullptr'; @@ -261,7 +285,7 @@ protected function formatMatchReturn(NodeAbstract $body, string $nativeClass = ' $this->addLocalVar($tmpVar, $this->getNativeObjectPointerType($nativeClass)); $this->addNativeObject($tmpVar, $nativeClass); } else { - $tmpVar = $this->addTmpVar(Type::VAR); + $tmpVar = $this->addTmpVar($returnType); } $code .= $this->getIndent() . "{$tmpVar} = {$value};" . PHP_EOL; $code .= $this->formatCapturedStmtLines($afterStmts); From 6a62c2deffdf67cc224f70a37322b12f3ef7e47b Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Thu, 15 Oct 2026 18:16:13 +0545 Subject: [PATCH 2/2] test(compiler): use portable integer literal suffix matching in MatchExpressionCodegenTest --- phpunit/src/MatchExpressionCodegenTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/phpunit/src/MatchExpressionCodegenTest.php b/phpunit/src/MatchExpressionCodegenTest.php index 4e71d930..9a57b868 100644 --- a/phpunit/src/MatchExpressionCodegenTest.php +++ b/phpunit/src/MatchExpressionCodegenTest.php @@ -41,9 +41,9 @@ public function testUniformIntMatchInfersIntAndAvoidsRuntimeReturnTypeCheck(): v $fnCode = $this->extractFunction('php_testuniformintmatch'); self::assertStringNotContainsString('php::Var', $fnCode); self::assertStringNotContainsString('php::throwReturnTypeError', $fnCode); - self::assertStringContainsString('return 10LL;', $fnCode); - self::assertStringContainsString('return 20LL;', $fnCode); - self::assertStringContainsString('return 30LL;', $fnCode); + self::assertMatchesRegularExpression('/return 10L{1,2};/', $fnCode); + self::assertMatchesRegularExpression('/return 20L{1,2};/', $fnCode); + self::assertMatchesRegularExpression('/return 30L{1,2};/', $fnCode); } public function testUniformStringMatchInfersString(): void