diff --git a/phpunit/code/closure-param-type-class.php b/phpunit/code/closure-param-type-class.php new file mode 100644 index 00000000..205ccbb4 --- /dev/null +++ b/phpunit/code/closure-param-type-class.php @@ -0,0 +1,9 @@ + $x + 1; + return $fn(42); + } +} diff --git a/phpunit/code/closure-param-type-varint.php b/phpunit/code/closure-param-type-varint.php new file mode 100644 index 00000000..697fbab2 --- /dev/null +++ b/phpunit/code/closure-param-type-varint.php @@ -0,0 +1,123 @@ + $p + 1; + $i = 42; + return $fn($i); +} + +function varintTypeDeclFloat(float $vd2): float +{ + $fn = fn(float $p) => $p * 2.0; + $f = 3.14; + return $fn($f); +} + +// --- Inferred types in call sites --- + +function varintInferredInt(): int +{ + $fn = fn(int $vi1) => $vi1 + 1; + $a = 10; + $b = 5; + return $fn($a + $b); +} + +function varintInferredIntSub(): int +{ + $fn = fn(int $vis1) => $vis1 - 1; + $a = 20; + $b = 3; + return $fn($a - $b); +} + +function varintInferredIntMul(): int +{ + $fn = fn(int $vim1) => $vim1 * 2; + $a = 7; + $b = 4; + return $fn($a * $b); +} + +function varintInferredIntMod(): int +{ + $fn = fn(int $vimod1) => $vimod1 + 1; + $a = 10; + $b = 3; + return $fn($a % $b); +} + +function varintInferredIntShiftLeft(): int +{ + $fn = fn(int $visl1) => $visl1 + 1; + $a = 3; + $b = 2; + return $fn($a << $b); +} + +function varintInferredIntShiftRight(): int +{ + $fn = fn(int $visr1) => $visr1 + 1; + $a = 16; + $b = 2; + return $fn($a >> $b); +} + +// --- Mixed type scenarios --- + +function varintMixedParams(): array +{ + $fn = fn(int $vmx1, float $vmx2) => [$vmx1, $vmx2]; + $i = 42; + $f = 3.14; + return $fn($i, $f); +} + +function varintBinaryPow(): int +{ + $fn = fn(int $vbp1) => $vbp1 + 1; + $a = 2; + $b = 3; + return $fn($a ** $b); +} + +// --- Float division in varint mode (non-constant → Variant) --- + +function varintFloatDiv(): void +{ + $fn = fn($vfdiv1) => $vfdiv1; + $a = 3.14; + $b = 2; + var_dump($fn($a / $b)); +} + +// --- Main function for testing --- + +function main(): void +{ + echo "varintTypeDeclInt: " . varintTypeDeclInt(0) . "\n"; + echo "varintTypeDeclFloat: " . varintTypeDeclFloat(0.0) . "\n"; + echo "varintInferredInt: " . varintInferredInt() . "\n"; + echo "varintInferredIntSub: " . varintInferredIntSub() . "\n"; + echo "varintInferredIntMul: " . varintInferredIntMul() . "\n"; + echo "varintInferredIntMod: " . varintInferredIntMod() . "\n"; + echo "varintInferredIntShiftLeft: " . varintInferredIntShiftLeft() . "\n"; + echo "varintInferredIntShiftRight: " . varintInferredIntShiftRight() . "\n"; + $mixed = varintMixedParams(); + echo "varintMixedParams: [" . $mixed[0] . ", " . $mixed[1] . "]\n"; + echo "varintBinaryPow: " . varintBinaryPow() . "\n"; + varintFloatDiv(); +} diff --git a/phpunit/code/closure-param-type.php b/phpunit/code/closure-param-type.php new file mode 100644 index 00000000..a08a9d3b --- /dev/null +++ b/phpunit/code/closure-param-type.php @@ -0,0 +1,686 @@ + $p + 1; + $i = 42; + return $fn($i); +} + +function typeDeclVarFloat(float $v): float +{ + $fn = fn(float $p) => $p * 2.0; + $f = 3.14; + return $fn($f); +} + +function typeDeclVarString(string $v): int +{ + $fn = fn(string $p) => strlen($p); + $s = "hello"; + return $fn($s); +} + +function typeDeclVarBool(bool $v): bool +{ + $fn = fn(bool $p) => !$p; + $b = true; + return $fn($b); +} + +// --- Type declaration + literal (no conversion needed) --- + +function typeDeclLitInt(): int +{ + $fn = fn(int $q) => $q + 1; + return $fn(42); +} + +function typeDeclLitFloat(): float +{ + $fn = fn(float $q) => $q * 2.0; + return $fn(3.14); +} + +function typeDeclLitString(): int +{ + $fn = fn(string $q) => strlen($q); + return $fn("hello"); +} + +function typeDeclLitBool(): bool +{ + $fn = fn(bool $q) => !$q; + return $fn(true); +} + +// --- Type declaration wins over call-site inference --- + +function typeDeclWinsOverInfer(): int +{ + $fn = fn(int $r) => $r + 1; + $s = "not an int"; + return $fn($s); +} + +// --- Call-site literal inference (no type declaration) --- + +function callSiteInt(): int +{ + $fn = fn($s1) => $s1 + 1; + return $fn(42); +} + +function callSiteFloat(): float +{ + $fn = fn($s2) => $s2 * 2.0; + return $fn(3.14); +} + +function callSiteBool(): bool +{ + $fn = fn($s3) => !$s3; + return $fn(true); +} + +function callSiteArray(): int +{ + $fn = fn($s4) => count($s4); + return $fn([1, 2, 3]); +} + +// --- Multi-call fallback (all call sites disagree) --- + +function multiCallFallback(): void +{ + $fn = fn($m1) => $m1 + 1; + var_dump($fn(42)); + var_dump($fn(3.14)); +} + +// --- Unary expressions --- + +function unaryNegInt(): int +{ + $fn = fn($u1) => $u1 + 1; + return $fn(-42); +} + +function unaryNegFloat(): float +{ + $fn = fn($u2) => $u2 * 2.0; + return $fn(-3.14); +} + +function unaryPlus(): int +{ + $fn = fn($u3) => $u3 + 1; + return $fn(+42); +} + +// --- Cast expressions --- + +function castInt(): int +{ + $fn = fn($c1) => $c1 + 1; + return $fn((int)"42"); +} + +function castString(): string +{ + $fn = fn($c2) => $c2; + return $fn((string)42); +} + +function castFloat(): float +{ + $fn = fn($c3) => $c3 * 2.0; + return $fn((float)"3.14"); +} + +function castBool(): bool +{ + $fn = fn($c4) => !$c4; + return $fn((bool)1); +} + +// --- goto invalidates candidates --- + +function gotoInvalidates(): void +{ + $fn = fn($g1) => $g1 + 1; + var_dump($fn(1)); + goto end; + end: +} + +// --- Spaceship operator (returns int, not bool) --- + +function spaceshipReturnsVar(): void +{ + $fn = fn($sp1) => $sp1; + var_dump($fn(1 <=> 2)); +} + +// --- Unary on bool operand (should stay VAR, not bool) --- + +function unaryNegBool(): void +{ + $fn = fn($un1) => $un1; + var_dump($fn(-true)); +} + +// --- High-precision decimal literal --- + +function decimalLiteralInfersDecimal(): void +{ + $fn = fn($dl1) => $dl1; + var_dump($fn(3.14159265358979323846)); +} + +// --- Expression arguments (not just literals/variables) --- + +function exprArithAdd(): int +{ + $fn = fn($ea1) => $ea1 + 1; + return $fn(1 + 2); +} + +function exprArithMulFloat(): float +{ + $fn = fn($ea2) => $ea2 * 2.0; + return $fn(3.14 * 2.0); +} + +function exprLogicalOr(): void +{ + $fn = fn($eo1) => $eo1; + var_dump($fn(true || false)); +} + +function exprConcatString(): void +{ + $fn = fn($ec1) => $ec1; + var_dump($fn("hello" . "world")); +} + +function exprComparisonReturnsBool(): void +{ + $fn = fn($ev1) => $ev1; + var_dump($fn(1 === 2)); +} + +function exprTernary(): void +{ + $fn = fn($et1) => $et1; + var_dump($fn(1 ? 42 : 0)); +} + +function exprFuncCallReturnsInt(): void +{ + $fn = fn($ef1) => $ef1; + var_dump($fn(strlen("hello"))); +} + +// --- Multiple same-type call sites (should narrow) --- + +function multiSameTypeNarrows(): void +{ + $fn = fn($ms1) => $ms1 + 1; + var_dump($fn(10)); + var_dump($fn(20)); + var_dump($fn(30)); +} + +// --- Binary operators (not just +) --- + +function binarySub(): int +{ + $fn = fn($bs1) => $bs1; + return $fn(1 - 2); +} + +function binaryDivFloat(): float +{ + $fn = fn($bd1) => $bd1; + return $fn(6.0 / 2); +} + +function binaryMod(): int +{ + $fn = fn($bm1) => $bm1; + return $fn(10 % 3); +} + +function binaryPow(): int +{ + $fn = fn($bp1) => $bp1; + return $fn(2 ** 3); +} + +// --- Bitwise / boolean operators --- + +function bitwiseNot(): int +{ + $fn = fn($bn1) => $bn1; + return $fn(~1); +} + +function booleanNot(): bool +{ + $fn = fn($bt1) => $bt1; + return $fn(!true); +} + +function booleanAnd(): bool +{ + $fn = fn($ba1) => $ba1; + return $fn(true && false); +} + +function logicalXor(): bool +{ + $fn = fn($bx1) => $bx1; + return $fn(true xor false); +} + +// --- Null and empty array edge cases --- + +function nullLiteral(): void +{ + $fn = fn($nl1) => $nl1; + var_dump($fn(null)); +} + +function emptyArray(): array +{ + $fn = fn($ea3) => $ea3; + return $fn([]); +} + +// --- Multi-param closures --- + +function multiParamAllInt(): void +{ + $fn = fn($mp1, $mp2) => $mp1 + $mp2; + var_dump($fn(10, 20)); +} + +function multiParamAllFloat(): void +{ + $fn = fn($mp3, $mp4) => $mp3 + $mp4; + var_dump($fn(1.0, 2.0)); +} + +function multiParamMixedTypes(): void +{ + $fn = fn($mp5, $mp6) => [$mp5, $mp6]; + var_dump($fn(1, "hello")); +} + +// --- Multi-call same non-int types --- + +function multiCallAllFloat(): void +{ + $fn = fn($mf1) => $mf1; + var_dump($fn(1.0)); + var_dump($fn(2.0)); + var_dump($fn(3.0)); +} + +function multiCallAllString(): void +{ + $fn = fn($ms2) => $ms2; + var_dump($fn("a")); + var_dump($fn("b")); + var_dump($fn("c")); +} + +function multiCallAllBool(): void +{ + $fn = fn($mb1) => $mb1; + var_dump($fn(true)); + var_dump($fn(false)); + var_dump($fn(true)); +} + +// --- Multi-call 2 same + 1 disagree → VAR --- + +function multiCallTwoSameOneDiff(): void +{ + $fn = fn($md1) => $md1; + var_dump($fn(1)); + var_dump($fn(2)); + var_dump($fn(3.0)); +} + +// --- Ternary mixed branches → VAR --- + +function ternaryMixedBranches(): void +{ + $fn = fn($tm1) => $tm1; + var_dump($fn(1 ? 42 : "str")); +} + +// --- Const fetch --- + +function constFetchInt(): void +{ + $fn = fn($cf1) => $cf1; + var_dump($fn(PHP_INT_MAX)); +} + +// --- Nested functions (still narrowed) --- + +function nestedFnStillNarrowed(): int +{ + $fn = fn($n1) => $n1 + 1; + return $fn(42); +} + +// --- Call-site string literal --- + +function callSiteString(): int +{ + $fn = fn($cs1) => strlen($cs1); + return $fn("hello"); +} + +// --- Binary shift --- + +function binaryShiftLeft(): int +{ + $fn = fn($sl1) => $sl1; + return $fn(1 << 3); +} + +// --- Binary bitwise or --- + +function binaryBitwiseOr(): int +{ + $fn = fn($bo1) => $bo1; + return $fn(0b1010 | 0b1100); +} + +// --- Null coalesce (not in detectTypeOfExpr switch → VAR) --- + +function nullCoalesce(): void +{ + $nc_var = 1; + $fn = fn($nc1) => $nc1; + var_dump($fn($nc_var ?? 0)); +} + +// --- Multi-param with type declarations + mismatched args --- + +function multiParamTypeDeclMismatch(): void +{ + $fn = fn(int $mt1, string $mt2) => [$mt1, $mt2]; + var_dump($fn("hello", 42)); +} + +// --- Binary shift right --- + +function binaryShiftRight(): int +{ + $fn = fn($sr1) => $sr1; + return $fn(8 >> 1); +} + +// --- Binary bitwise and --- + +function binaryBitwiseAnd(): int +{ + $fn = fn($ba2) => $ba2; + return $fn(0b1010 & 0b1100); +} + +// --- Binary bitwise xor --- + +function binaryBitwiseXor(): int +{ + $fn = fn($bx2) => $bx2; + return $fn(0b1010 ^ 0b1100); +} + +// --- Comparison not equal --- + +function comparisonNotEqual(): bool +{ + $fn = fn($ne1) => $ne1; + return $fn(1 != 2); +} + +// --- Cast array --- + +function castArray(): array +{ + $fn = fn($ca1) => $ca1; + return $fn((array)42); +} + +// --- Const fetch true --- + +function constFetchTrue(): bool +{ + $fn = fn($ct1) => $ct1; + return $fn(true); +} + +// --- Nullable type declaration --- + +function nullableIntTypeDecl(): void +{ + $fn = fn(?int $ni1) => $ni1; + var_dump($fn(42)); +} + +function nullableIntWithNull(): void +{ + $fn = fn(?int $ni2) => $ni2; + var_dump($fn(42)); + var_dump($fn(null)); +} + +// --- Comparison operators (==, <, <=, >, >=) --- + +function comparisonEqual(): bool +{ + $fn = fn($ce1) => $ce1; + return $fn(1 == 2); +} + +function comparisonLessThan(): bool +{ + $fn = fn($clt1) => $clt1; + return $fn(1 < 2); +} + +function comparisonLessEqual(): bool +{ + $fn = fn($cle1) => $cle1; + return $fn(1 <= 2); +} + +function comparisonGreaterThan(): bool +{ + $fn = fn($cgt1) => $cgt1; + return $fn(2 > 1); +} + +function comparisonGreaterEqual(): bool +{ + $fn = fn($cge1) => $cge1; + return $fn(2 >= 1); +} + +// --- ConstFetch false, NAN, INF --- + +function constFetchFalse(): bool +{ + $fn = fn($cf2) => $cf2; + return $fn(false); +} + +function constFetchNan(): float +{ + $fn = fn($cn1) => $cn1; + return $fn(NAN); +} + +function constFetchInf(): float +{ + $fn = fn($ci1) => $ci1; + return $fn(INF); +} + +// --- Union type declaration (always VAR) --- + +function unionTypeDecl(): void +{ + $fn = fn(int|string $ut1) => $ut1; + var_dump($fn(42)); +} + +// --- Binary mul int --- + +function binaryMulInt(): int +{ + $fn = fn($bmi1) => $bmi1; + return $fn(2 * 3); +} + +// --- Type declaration scenarios: fallback to VAR + runtime check --- + +function arrayTypeDecl(): void +{ + $fn = fn(array $x) => count($x); + var_dump($fn([1, 2, 3])); +} + +function objectTypeDecl(): void +{ + $fn = fn(object $x) => $x; + var_dump($fn(new \stdClass())); +} + +function classTypeDecl(): void +{ + $fn = fn(\DateTime $x) => $x->format('Y'); + var_dump($fn(new \DateTime())); +} + +function intTypeDecl(): void +{ + $fn = fn(int $x) => $x + 1; + var_dump($fn(42)); +} + +function inferredArrayNoDecl(): int +{ + $fn = fn($x) => count($x); + return $fn([1, 2, 3]); +} + +// --- Modulo with float operands (php::fn::mod → Variant) --- + +function binaryModFloat(): void +{ + $fn = fn($bmf1) => $bmf1; + var_dump($fn(3.14 % 2)); +} + +// --- Entry point --- +function main(): void +{ + typeDeclVarInt(10); + typeDeclVarFloat(1.5); + typeDeclVarString("test"); + typeDeclVarBool(false); + typeDeclLitInt(); + typeDeclLitFloat(); + typeDeclLitString(); + typeDeclLitBool(); + typeDeclWinsOverInfer(); + callSiteInt(); + callSiteFloat(); + callSiteBool(); + callSiteArray(); + callSiteString(); + multiCallFallback(); + unaryNegInt(); + unaryNegFloat(); + unaryPlus(); + castInt(); + castString(); + castFloat(); + castBool(); + gotoInvalidates(); + exprArithAdd(); + exprArithMulFloat(); + exprLogicalOr(); + exprConcatString(); + exprComparisonReturnsBool(); + exprTernary(); + exprFuncCallReturnsInt(); + spaceshipReturnsVar(); + unaryNegBool(); + decimalLiteralInfersDecimal(); + multiSameTypeNarrows(); + nestedFnStillNarrowed(); + binarySub(); + binaryDivFloat(); + binaryMod(); + binaryPow(); + bitwiseNot(); + booleanNot(); + booleanAnd(); + logicalXor(); + nullLiteral(); + emptyArray(); + multiParamAllInt(); + multiParamAllFloat(); + multiParamMixedTypes(); + multiCallAllFloat(); + multiCallAllString(); + multiCallAllBool(); + multiCallTwoSameOneDiff(); + ternaryMixedBranches(); + constFetchInt(); + binaryShiftLeft(); + binaryBitwiseOr(); + nullCoalesce(); + multiParamTypeDeclMismatch(); + binaryShiftRight(); + binaryBitwiseAnd(); + binaryBitwiseXor(); + comparisonNotEqual(); + castArray(); + constFetchTrue(); + nullableIntTypeDecl(); + nullableIntWithNull(); + comparisonEqual(); + comparisonLessThan(); + comparisonLessEqual(); + comparisonGreaterThan(); + comparisonGreaterEqual(); + constFetchFalse(); + constFetchNan(); + constFetchInf(); + unionTypeDecl(); + binaryMulInt(); + arrayTypeDecl(); + objectTypeDecl(); + classTypeDecl(); + intTypeDecl(); + inferredArrayNoDecl(); + binaryModFloat(); +} diff --git a/phpunit/src/ClosureParamTypeTest.php b/phpunit/src/ClosureParamTypeTest.php new file mode 100644 index 00000000..86d38b0b --- /dev/null +++ b/phpunit/src/ClosureParamTypeTest.php @@ -0,0 +1,393 @@ +addFiles([$source]); + $compiler->prepareFile($source); + return file_get_contents($compiler->convertFile($source)); + } + + // --- Type declaration narrows to native type (variable args) --- + + public function testTypeDeclVarNarrowsToNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_typedeclvarint\(.*?\n\tauto fn = \[\]\(php::Int p\)/s', $code); + self::assertMatchesRegularExpression('/php_typedeclvarfloat\(.*?\n\tauto fn = \[\]\(php::Float p\)/s', $code); + self::assertMatchesRegularExpression('/php_typedeclvarstring\(.*?\n\tauto fn = \[\]\(php::Str p\)/s', $code); + self::assertMatchesRegularExpression('/php_typedeclvarbool\(.*?\n\tauto fn = \[\]\(php::Bool p\)/s', $code); + } + + // --- Type declaration + literal args --- + + public function testTypeDeclLitUsesNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_typedecllitint\(.*?\n\tauto fn = \[\]\(php::Int q\)/s', $code); + self::assertMatchesRegularExpression('/php_typedecllitfloat\(.*?\n\tauto fn = \[\]\(php::Float q\)/s', $code); + self::assertMatchesRegularExpression('/php_typedecllitstring\(.*?\n\tauto fn = \[\]\(php::Str q\)/s', $code); + self::assertMatchesRegularExpression('/php_typedecllitbool\(.*?\n\tauto fn = \[\]\(php::Bool q\)/s', $code); + } + + // --- Type declaration wins over call-site inference --- + + public function testTypeDeclWinsOverInferInt(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_typedeclwinsoverinfer\(.*?\n\tauto fn = \[\]\(php::Int r\)/s', $code); + self::assertStringContainsString('toIntArgExact', $code); + } + + // --- Call-site literal inference --- + + public function testCallSiteInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_callsiteint\(.*?\n\tauto fn = \[\]\(php::Int s1\)/s', $code); + self::assertMatchesRegularExpression('/php_callsitefloat\(.*?\n\tauto fn = \[\]\(php::Float s2\)/s', $code); + self::assertMatchesRegularExpression('/php_callsitebool\(.*?\n\tauto fn = \[\]\(php::Bool s3\)/s', $code); + self::assertMatchesRegularExpression('/php_callsitearray\(.*?\n\tauto fn = \[\]\(php::Array s4\)/s', $code); + self::assertMatchesRegularExpression('/php_callsitestring\(.*?\n\tauto fn = \[\]\(php::Str cs1\)/s', $code); + } + + // --- Unary expressions --- + + public function testUnaryInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_unarynegint\(.*?\n\tauto fn = \[\]\(php::Int u1\)/s', $code); + self::assertMatchesRegularExpression('/php_unarynegfloat\(.*?\n\tauto fn = \[\]\(php::Float u2\)/s', $code); + self::assertMatchesRegularExpression('/php_unaryplus\(.*?\n\tauto fn = \[\]\(php::Int u3\)/s', $code); + } + + // --- Cast expressions --- + + public function testCastInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_castint\(.*?\n\tauto fn = \[\]\(php::Int c1\)/s', $code); + self::assertMatchesRegularExpression('/php_caststring\(.*?\n\tauto fn = \[\]\(php::Var c2\)/s', $code); + self::assertMatchesRegularExpression('/php_castfloat\(.*?\n\tauto fn = \[\]\(php::Float c3\)/s', $code); + self::assertMatchesRegularExpression('/php_castbool\(.*?\n\tauto fn = \[\]\(php::Bool c4\)/s', $code); + self::assertMatchesRegularExpression('/php_castarray\(.*?\n\tauto fn = \[\]\(php::Array ca1\)/s', $code); + } + + // --- Binary operators --- + + public function testBinaryOpInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_binarysub\(.*?\n\tauto fn = \[\]\(php::Int bs1\)/s', $code); + self::assertMatchesRegularExpression('/php_binarydivfloat\(.*?\n\tauto fn = \[\]\(php::Float bd1\)/s', $code); + self::assertMatchesRegularExpression('/php_binarymod\(.*?\n\tauto fn = \[\]\(php::Int bm1\)/s', $code); + self::assertMatchesRegularExpression('/php_binarypow\(.*?\n\tauto fn = \[\]\(php::Var bp1\)/s', $code); + self::assertMatchesRegularExpression('/php_binarymulint\(.*?\n\tauto fn = \[\]\(php::Int bmi1\)/s', $code); + self::assertMatchesRegularExpression('/php_binaryshiftleft\(.*?\n\tauto fn = \[\]\(php::Int sl1\)/s', $code); + self::assertMatchesRegularExpression('/php_binaryshiftright\(.*?\n\tauto fn = \[\]\(php::Int sr1\)/s', $code); + self::assertMatchesRegularExpression('/php_binarybitwiseand\(.*?\n\tauto fn = \[\]\(php::Int ba2\)/s', $code); + self::assertMatchesRegularExpression('/php_binarybitwiseor\(.*?\n\tauto fn = \[\]\(php::Int bo1\)/s', $code); + self::assertMatchesRegularExpression('/php_binarybitwisexor\(.*?\n\tauto fn = \[\]\(php::Int bx2\)/s', $code); + } + + // --- Bitwise / boolean operators --- + + public function testBitwiseBooleanInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_bitwisenot\(.*?\n\tauto fn = \[\]\(php::Int bn1\)/s', $code); + self::assertMatchesRegularExpression('/php_booleannot\(.*?\n\tauto fn = \[\]\(php::Bool bt1\)/s', $code); + self::assertMatchesRegularExpression('/php_booleanand\(.*?\n\tauto fn = \[\]\(php::Bool ba1\)/s', $code); + self::assertMatchesRegularExpression('/php_logicalxor\(.*?\n\tauto fn = \[\]\(php::Bool bx1\)/s', $code); + } + + // --- Comparison operators --- + + public function testComparisonInfersBool(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_comparisonequal\(.*?\n\tauto fn = \[\]\(php::Bool ce1\)/s', $code); + self::assertMatchesRegularExpression('/php_comparisonnotequal\(.*?\n\tauto fn = \[\]\(php::Bool ne1\)/s', $code); + self::assertMatchesRegularExpression('/php_comparisonlessthan\(.*?\n\tauto fn = \[\]\(php::Bool clt1\)/s', $code); + self::assertMatchesRegularExpression('/php_comparisonlessequal\(.*?\n\tauto fn = \[\]\(php::Bool cle1\)/s', $code); + self::assertMatchesRegularExpression('/php_comparisongreaterthan\(.*?\n\tauto fn = \[\]\(php::Bool cgt1\)/s', $code); + self::assertMatchesRegularExpression('/php_comparisongreaterequal\(.*?\n\tauto fn = \[\]\(php::Bool cge1\)/s', $code); + } + + // --- Expression arguments --- + + public function testExprArgsInferType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_exprarithadd\(.*?\n\tauto fn = \[\]\(php::Int ea1\)/s', $code); + self::assertMatchesRegularExpression('/php_exprarithmulfloat\(.*?\n\tauto fn = \[\]\(php::Float ea2\)/s', $code); + self::assertMatchesRegularExpression('/php_exprlogicalor\(.*?\n\tauto fn = \[\]\(php::Bool eo1\)/s', $code); + self::assertMatchesRegularExpression('/php_exprconcatstring\(.*?\n\tauto fn = \[\]\(php::Str ec1\)/s', $code); + self::assertMatchesRegularExpression('/php_exprcomparisonreturnsbool\(.*?\n\tauto fn = \[\]\(php::Bool ev1\)/s', $code); + self::assertMatchesRegularExpression('/php_exprternary\(.*?\n\tauto fn = \[\]\(php::Int et1\)/s', $code); + self::assertMatchesRegularExpression('/php_exprfunccallreturnsint\(.*?\n\tauto fn = \[\]\(php::Int ef1\)/s', $code); + } + + // --- Const fetch --- + + public function testConstFetchInfersType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_constfetchint\(.*?\n\tauto fn = \[\]\(php::Int cf1\)/s', $code); + self::assertMatchesRegularExpression('/php_constfetchtrue\(.*?\n\tauto fn = \[\]\(php::Bool ct1\)/s', $code); + self::assertMatchesRegularExpression('/php_constfetchfalse\(.*?\n\tauto fn = \[\]\(php::Bool cf2\)/s', $code); + self::assertMatchesRegularExpression('/php_constfetchnan\(.*?\n\tauto fn = \[\]\(php::Float cn1\)/s', $code); + self::assertMatchesRegularExpression('/php_constfetchinf\(.*?\n\tauto fn = \[\]\(php::Float ci1\)/s', $code); + } + + // --- Multi-param closures --- + + public function testMultiParamInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_multiparamallint\(.*?\n\tauto fn = \[\]\(php::Int mp1, php::Int mp2\)/s', $code); + self::assertMatchesRegularExpression('/php_multiparamallfloat\(.*?\n\tauto fn = \[\]\(php::Float mp3, php::Float mp4\)/s', $code); + self::assertMatchesRegularExpression('/php_multiparammixedtypes\(.*?\n\tauto fn = \[\]\(php::Int mp5, php::Str mp6\)/s', $code); + } + + // --- Multi-call same type narrows --- + + public function testMultiCallSameTypeNarrows(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_multisametypenarrows\(.*?\n\tauto fn = \[\]\(php::Int ms1\)/s', $code); + self::assertMatchesRegularExpression('/php_multicallallfloat\(.*?\n\tauto fn = \[\]\(php::Float mf1\)/s', $code); + self::assertMatchesRegularExpression('/php_multicallallstring\(.*?\n\tauto fn = \[\]\(php::Str ms2\)/s', $code); + self::assertMatchesRegularExpression('/php_multicallallbool\(.*?\n\tauto fn = \[\]\(php::Bool mb1\)/s', $code); + } + + // --- Multi-call disagree → VAR --- + + public function testMultiCallFallback(): void + { + $code = $this->compileFixture('closure-param-type.php'); + // different types across call sites → VAR + self::assertMatchesRegularExpression('/php_multicallfallback\(.*?\n\tauto fn = \[\]\(php::Var m1\)/s', $code); + // 2 agree + 1 disagree → VAR + self::assertMatchesRegularExpression('/php_multicalltwosameonediff\(.*?\n\tauto fn = \[\]\(php::Var md1\)/s', $code); + } + + // --- Null / edge cases --- + + public function testNullAndEdgeCases(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_nullliteral\(.*?\n\tauto fn = \[\]\(php::Var nl1\)/s', $code); + self::assertMatchesRegularExpression('/php_emptyarray\(.*?\n\tauto fn = \[\]\(php::Array ea3\)/s', $code); + self::assertMatchesRegularExpression('/php_nullcoalesce\(.*?\n\tauto fn = \[\]\(php::Var nc1\)/s', $code); + } + + // ===== Unique scenario tests (each verifies a distinct behavior) ===== + + public function testGotoInvalidatesAllCandidates(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('newClosureWithParameters', $code); + } + + public function testNestedFnStillNarrowed(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_nestedfnstillnarrowed\(.*?\n\tauto fn = \[\]\(php::Int n1\)/s', $code); + } + + public function testClassMethodClosureStaysZend(): void + { + $code = $this->compileFixture('closure-param-type-class.php'); + self::assertStringNotContainsString('(php::Int p)', $code); + self::assertStringContainsString('newClosureWithParameters', $code); + } + + // --- Negative tests: operator result types --- + + public function testSpaceshipDoesNotNarrowToBool(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_spaceshipreturnsvar\(.*?\n\tauto fn = \[\]\(php::Var sp1\)/s', $code); + self::assertStringNotContainsString('(php::Bool sp1)', $code); + } + + public function testUnaryNegBoolDoesNotNarrowToBool(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_unarynegbool\(.*?\n\tauto fn = \[\]\(php::Var un1\)/s', $code); + self::assertStringNotContainsString('(php::Bool un1)', $code); + } + + public function testDecimalLiteralInfersDecimalType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_decimalliteralinfersdecimal\(.*?\n\tauto fn = \[\]\(php::Var dl1\)/s', $code); + self::assertStringNotContainsString('(php::Decimal dl1)', $code); + } + + // --- Multi-param type decl mismatch --- + + public function testMultiParamTypeDeclMismatch(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_multiparamtypedeclmismatch\(.*?\n\tauto fn = \[\]\(php::Int mt1, php::Str mt2\)/s', $code); + self::assertStringContainsString('toIntArgExact', $code); + self::assertStringContainsString('toStringArgExact', $code); + } + + // --- Ternary mixed branches → VAR --- + + public function testTernaryMixedBranchesInfersVar(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_ternarymixedbranches\(.*?\n\tauto fn = \[\]\(php::Var tm1\)/s', $code); + } + + // --- Nullable type declaration: always VAR with runtime check --- + + public function testNullableIntDeclKeepsRuntimeCheck(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_nullableinttypedecl\(.*?\n\tauto fn = \[\]\(php::Var ni1\)/s', $code); + self::assertStringContainsString('ni1.isNull() || ni1.isInt()', $code); + self::assertStringNotContainsString('(php::Int ni1)', $code); + } + + public function testNullableIntWithNullBothCallSites(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_nullableintwithnull\(.*?\n\tauto fn = \[\]\(php::Var ni2\)/s', $code); + self::assertStringContainsString('ni2.isNull() || ni2.isInt()', $code); + self::assertStringNotContainsString('(php::Int ni2)', $code); + } + + // --- Union type declaration: always VAR --- + + public function testUnionTypeDeclKeepsVar(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_uniontypedecl\(.*?\n\tauto fn = \[\]\(php::Var ut1\)/s', $code); + } + + // --- Array/object/class type declarations: fallback to VAR --- + + public function testArrayTypeDeclKeepsVarWithRuntimeCheck(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_arraytypedecl\(.*?\n\tauto fn = \[\]\(php::Var x\)/s', $code); + self::assertStringContainsString('isArray', $code); + } + + public function testObjectTypeDeclKeepsVarWithRuntimeCheck(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_objecttypedecl\(.*?\n\tauto fn = \[\]\(php::Var x\)/s', $code); + self::assertStringContainsString('isObject', $code); + } + + public function testClassTypeDeclKeepsVarWithRuntimeCheck(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_classtypedecl\(.*?\n\tauto fn = \[\]\(php::Var x\)/s', $code); + self::assertStringContainsString('instanceOf', $code); + } + + public function testIntTypeDeclKeepsNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_inttypedecl\(.*?\n\tauto fn = \[\]\(php::Int x\)/s', $code); + } + + public function testInferredArrayKeepsNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_inferredarraynodecl\(.*?\n\tauto fn = \[\]\(php::Array x\)/s', $code); + } + + public function testNoDecimalOrBigIntInLambdaParam(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringNotContainsString('php::BigInt', $code); + self::assertStringNotContainsString('php::Decimal', $code); + self::assertStringNotContainsString('php::BigFloat', $code); + } + + // --- varint_types mode: closure params use php::Var for inferred ints --- + + public function testVarintModeTypeDeclStillUsesNativeType(): void + { + $code = $this->compileFixture('closure-param-type-varint.php'); + self::assertMatchesRegularExpression('/php_varinttypedeclint\(.*?\n\tauto fn = \[\]\(php::Int p\)/s', $code); + self::assertMatchesRegularExpression('/php_varinttypedeclfloat\(.*?\n\tauto fn = \[\]\(php::Float p\)/s', $code); + } + + public function testVarintModeInferredIntUsesVar(): void + { + $code = $this->compileFixture('closure-param-type-varint.php'); + // Lambda parameter is php::Int (type declaration), call site uses toIntArgExact + self::assertMatchesRegularExpression('/php_varintinferredint\(.*?\n\tauto fn = \[\]\(php::Int vi1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(((a) + (b)), "{closure}", 1, "vi1")', $code); + self::assertMatchesRegularExpression('/php_varintinferredintsub\(.*?\n\tauto fn = \[\]\(php::Int vis1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(((a) - (b)), "{closure}", 1, "vis1")', $code); + self::assertMatchesRegularExpression('/php_varintinferredintmul\(.*?\n\tauto fn = \[\]\(php::Int vim1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(((a) * (b)), "{closure}", 1, "vim1")', $code); + } + + public function testVarintModeModUsesVar(): void + { + $code = $this->compileFixture('closure-param-type-varint.php'); + self::assertMatchesRegularExpression('/php_varintinferredintmod\(.*?\n\tauto fn = \[\]\(php::Int vimod1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(php::fn::mod(a, b), "{closure}", 1, "vimod1")', $code); + } + + public function testVarintModeShiftUsesVar(): void + { + $code = $this->compileFixture('closure-param-type-varint.php'); + self::assertMatchesRegularExpression('/php_varintinferredintshiftleft\(.*?\n\tauto fn = \[\]\(php::Int visl1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(((a) << (b)), "{closure}", 1, "visl1")', $code); + self::assertMatchesRegularExpression('/php_varintinferredintshiftright\(.*?\n\tauto fn = \[\]\(php::Int visr1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(((a) >> (b)), "{closure}", 1, "visr1")', $code); + } + + public function testVarintModePowUsesVar(): void + { + $code = $this->compileFixture('closure-param-type-varint.php'); + self::assertMatchesRegularExpression('/php_varintbinarypow\(.*?\n\tauto fn = \[\]\(php::Int vbp1\)/s', $code); + self::assertStringContainsString('php::toIntArgExact(php::fn::pow(a, b), "{closure}", 1, "vbp1")', $code); + } + + // --- Modulo with float operands → php::fn::mod() → VAR --- + + public function testBinaryModFloatInfersVar(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertMatchesRegularExpression('/php_binarymodfloat\(.*?\n\tauto fn = \[\]\(php::Var bmf1\)/s', $code); + } + + // --- varint_types mode: float division → Variant --- + + public function testVarintModeFloatDivUsesVar(): void + { + $code = $this->compileFixture('closure-param-type-varint.php'); + self::assertMatchesRegularExpression('/php_varintfloatdiv\(.*?\n\tauto fn = \[\]\(php::Var vfdiv1\)/s', $code); + } +} diff --git a/phpunit/src/LocalClosureCodegenTest.php b/phpunit/src/LocalClosureCodegenTest.php index 5c271f07..6cbe612c 100644 --- a/phpunit/src/LocalClosureCodegenTest.php +++ b/phpunit/src/LocalClosureCodegenTest.php @@ -28,7 +28,7 @@ public function testOnlyProvenLocalClosuresUseConcreteCppLambdas(): void self::assertIsString($code); self::assertStringContainsString( - 'auto direct = [base = base](php::Var value) mutable -> php::Var {', + 'auto direct = [base = base](php::Int value) mutable -> php::Var {', $code, ); self::assertStringContainsString('direct(2L)', $code); diff --git a/src/Analysis/LocalClosureAnalyzer.php b/src/Analysis/LocalClosureAnalyzer.php index da88b509..5eeead5f 100644 --- a/src/Analysis/LocalClosureAnalyzer.php +++ b/src/Analysis/LocalClosureAnalyzer.php @@ -20,7 +20,7 @@ */ final class LocalClosureAnalyzer { - /** @var array */ + /** @var array}> */ private array $candidates = []; /** @var array */ @@ -31,7 +31,7 @@ final class LocalClosureAnalyzer /** * @param list $statements - * @return array + * @return array}> */ public function analyze(array $statements): array { @@ -63,6 +63,7 @@ public function analyze(array $statements): array 'assignment' => $statement->expr, 'closure' => $statement->expr->expr, 'calls' => 0, + 'callSites' => [], ]; } @@ -104,7 +105,7 @@ private function isSupportedClosure(Expr\Closure|Expr\ArrowFunction $closure): b return !$this->containsUnsupportedClosureNode($body, false); } - private function containsUnsupportedClosureNode(mixed $value, bool $root = true): bool + private function containsUnsupportedClosureNode(mixed $value, bool $root): bool { foreach (is_array($value) ? $value : [$value] as $node) { if (!$node instanceof Node) { @@ -152,6 +153,11 @@ private function scanNode( continue; } + // All candidates invalidated — nothing left to scan + if ($this->candidates === []) { + return; + } + // Textual order is not a dominance proof in the presence of goto: // a jump may bypass the lambda initialization or re-enter its // scope. Keep all such functions on the Zend Closure path. @@ -205,6 +211,7 @@ private function classifyVariableUse( } $this->candidates[$name]['calls']++; + $this->candidates[$name]['callSites'][] = $parent; } private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount): bool @@ -219,4 +226,5 @@ private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount) } return true; } + } diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index a4e52591..cb380aba 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -77,7 +77,12 @@ class FunctionContext * plans only; the generator moves a successfully lowered entry into * nativeLocalClosures when it emits the concrete C++ lambda. * - * @var array + * @var array, + * }> */ public array $localClosureCandidates = []; /** @var array Local variables already emitted as concrete C++ lambdas. */ diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index e1f130eb..d38ce364 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -129,9 +129,16 @@ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?stri $entryContext = $this->context; $entryIndent = $this->indentLevel; $entryInGeneratorBody = $this->inGeneratorBody; + + // Infer parameter types from call sites using compiler's type analysis + $inferredTypes = $this->inferParamTypesFromCallSites($candidate); + $parameters = []; - foreach ($expr->params as $param) { - $parameters[] = Type::VAR . ' ' . $this->parseIdentifier($param->var); + foreach ($expr->params as $i => $param) { + $inferredType = $inferredTypes[$i] ?? Type::VAR; + $paramType = $this->resolveEffectiveClosureParamType($param, $inferredType); + + $parameters[] = $paramType . ' ' . $this->parseIdentifier($param->var); } $code = 'auto ' . $name . ' = [' . implode(', ', $capturePlan['cpp']) . '](' @@ -158,7 +165,10 @@ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?stri $parameterChecks = ''; foreach ($expr->params as $index => $param) { $paramName = $this->parseIdentifier($param->var); - $this->addArgument($paramName, Type::VAR); + $inferredType = $inferredTypes[$index] ?? Type::VAR; + $effectiveType = $this->resolveEffectiveClosureParamType($param, $inferredType); + + $this->addArgument($paramName, $effectiveType); if (CompileTimeAttribute::consume($param, 'Immutable')) { $this->context->immutableVars[$paramName] = true; if ($this->immutableTypeNodeMayBeObject($param->type)) { @@ -174,7 +184,7 @@ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?stri } } } - $parameterChecks .= $this->genNativeLocalClosureParamTypeCheck($param, $paramName, $index); + $parameterChecks .= $this->genNativeLocalClosureParamTypeCheck($param, $paramName, $index, $effectiveType); } foreach ($capturePlan['bindings'] as $binding) { @@ -268,11 +278,17 @@ private function buildNativeLocalClosureCapturePlan(array $uses): ?array return ['cpp' => $cpp, 'bindings' => $bindings]; } - private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $var, int $index): string + private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $var, int $index, string $inferredType): string { if ($param->type === null) { return ''; } + + // Native-typed lambda uses C++ type directly; skip runtime check. + if (in_array($inferredType, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true)) { + return ''; + } + $typeInfo = $this->buildTypeCheckFromNode($param->type, true); if (empty($typeInfo['check'])) { return ''; @@ -290,15 +306,134 @@ private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $ return $this->genClosureParamCheck($argInfo, $index); } + /** + * Resolve the effective C++ type for a closure parameter. + * Type declaration takes priority over call-site inference. + * Call-site inference is used only when no type declaration exists. + * Nullable/Union/Intersection declarations always resolve to VAR — the + * runtime typeCheck must enforce the composite constraint. + */ + private function resolveEffectiveClosureParamType(Node\Param $param, string $inferredType): string + { + if ($param->type !== null) { + // Composite type declarations (?int, int|string, int&string) are + // uniformly treated as VAR at the static stage; the runtime + // typeCheck enforces the constraint. + if ($param->type instanceof NullableType || $param->type instanceof UnionType || $param->type instanceof IntersectionType) { + return Type::VAR; + } + [$declaredType, $className] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + if ($declaredType !== Type::VAR) { + // Array/Object/class parameters: the call boundary cannot safely + // convert from native scalars (zend_long, double) to these types. + // Keep as VAR so the runtime typeCheck inside the lambda enforces + // PHP semantics (TypeError on wrong argument type). + if ($declaredType === Type::ARRAY || $declaredType === Type::OBJECT || $className !== '') { + return Type::VAR; + } + return $declaredType; + } + } + if ($inferredType !== Type::VAR) { + return $inferredType; + } + return Type::VAR; + } + + /** + * Infer parameter types from call sites using the compiler's canonical + * type detection. Returns Type::VAR for a parameter position when call + * sites disagree or no call sites exist. + */ + private function inferParamTypesFromCallSites(array $candidate): array + { + $closure = $candidate['closure']; + $paramCount = count($closure->params); + $callSites = $candidate['callSites'] ?? []; + + if (count($callSites) === 0) { + return array_fill(0, $paramCount, Type::VAR); + } + + // Collect detected types per parameter position across all call sites + $allTypes = []; + foreach ($callSites as $callSite) { + $siteTypes = []; + foreach ($callSite->args as $i => $arg) { + $siteTypes[$i] = $this->inferCallSiteArgType($arg->value); + } + $allTypes[] = $siteTypes; + } + + // Narrow only when every call site agrees on the same type + $result = []; + for ($i = 0; $i < $paramCount; $i++) { + $firstType = $allTypes[0][$i] ?? Type::VAR; + $agree = true; + foreach ($allTypes as $perSite) { + if (($perSite[$i] ?? Type::VAR) !== $firstType) { + $agree = false; + break; + } + } + $result[$i] = $agree ? $firstType : Type::VAR; + } + return $result; + } + + /** + * Detect native type for call-site arguments, with edge-case overrides + * that detectTypeOfExpr does not cover for closure narrowing. + */ + private function inferCallSiteArgType(Expr $expr): string + { + $type = $this->detectTypeOfExpr($expr); + + // -true / +false: PHP coerces bool to int first, not bool. + if ($type === Type::BOOL && ($expr instanceof Expr\UnaryMinus || $expr instanceof Expr\UnaryPlus)) { + return Type::VAR; + } + + // Box subclasses (Decimal/BigInt/BigFloat) cannot be constructed from Variant. + if (in_array($type, [Type::DECIMAL, Type::BIGINT, Type::BIGFLOAT], true)) { + return Type::VAR; + } + + // php::fn::pow() returns Variant. + if ($expr instanceof Expr\BinaryOp\Pow) { + return Type::VAR; + } + + // php::fn::mod() returns Variant (non-INT operands). + if ($expr instanceof Expr\BinaryOp\Mod && $type === Type::FLOAT) { + return Type::VAR; + } + + // varint_types: all inferred locals and non-constant ops use php::Var. + if ($this->varIntTypes && in_array($type, [Type::INT, Type::FLOAT], true)) { + return Type::VAR; + } + + return $type; + } + protected function parseNativeLocalClosureCall(Expr\FuncCall $expr, string $name): ?string { if (!isset($this->context->nativeLocalClosures[$name])) { return null; } + // Look up candidate for type information + $candidate = $this->context->localClosureCandidates[$name] ?? null; + if ($candidate === null) { + return null; + } + $closure = $candidate['closure'] ?? null; + $inferredTypes = $this->inferParamTypesFromCallSites($candidate); + $arguments = []; $forceMaterialize = count($expr->args) > 1; - foreach ($expr->args as $argument) { + foreach ($expr->args as $i => $argument) { $this->assertExprCanBeUsedAsValue($argument->value, 'function argument'); if ($this->isVarExpr($argument->value)) { $this->assertStdContainerDoesNotEscapeNativeObjects( @@ -321,7 +456,31 @@ protected function parseNativeLocalClosureCall(Expr\FuncCall $expr, string $name } else { $value = $this->parseOrderedOperand($argument->value, false, $forceMaterialize); } - $arguments[] = $this->materializeCallArgValue($argument->value, $value); + $value = $this->materializeCallArgValue($argument->value, $value); + + // Cast variable args when effective type is native but inferred type is VAR. + // e.g. fn(float $x)($var) → call site generates toFloatArgExact($var, ...) + $inferredType = $inferredTypes[$i] ?? Type::VAR; + $param = $closure->params[$i] ?? null; + if ($param !== null) { + $effectiveType = $this->resolveEffectiveClosureParamType($param, $inferredType); + if ($effectiveType !== $inferredType) { + // effectiveType differs from inferred — need to cast at call site + $castFunc = match ($effectiveType) { + Type::INT => 'php::toIntArgExact', + Type::FLOAT => 'php::toFloatArgExact', + Type::BOOL => 'php::toBoolArgExact', + Type::STR => 'php::toStringArgExact', + default => null, + }; + if ($castFunc !== null) { + $paramName = is_string($param->var->name) ? $param->var->name : '?'; + $value = $castFunc . '(' . $value . ', "{closure}", ' . ($i + 1) . ', "' . $paramName . '")'; + } + } + } + + $arguments[] = $value; } return $name . '(' . implode(', ', $arguments) . ')'; } diff --git a/src/Translator.php b/src/Translator.php index 00f1c54b..1ed6a2ee 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -5090,7 +5090,8 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): } if ($v->stmts && !$this->class && $this->methodDef === null) { - $this->context->localClosureCandidates = (new LocalClosureAnalyzer())->analyze($v->stmts); + $analyzer = new LocalClosureAnalyzer(); + $this->context->localClosureCandidates = $analyzer->analyze($v->stmts); } $stmts = ''; diff --git a/tests/compiler/closure/closure-param-type-inference.phpt b/tests/compiler/closure/closure-param-type-inference.phpt new file mode 100644 index 00000000..9c37885d --- /dev/null +++ b/tests/compiler/closure/closure-param-type-inference.phpt @@ -0,0 +1,35 @@ +--TEST-- +Closure parameter type inference from call-site literals +--FILE-- + $x + 1; + var_dump($fn1(42)); + + $fn2 = fn($x) => $x * 2.0; + var_dump($fn2(3.14)); + + $fn3 = fn($x) => !$x; + var_dump($fn3(true)); + + $fn4 = fn($x) => count($x); + var_dump($fn4([1, 2])); + + $fn5 = fn(int $x) => $x + 1; + var_dump($fn5(42)); + + $fn6 = fn($x) => $x + 1; + var_dump($fn6(42)); + var_dump($fn6(3.14)); +} +?> +--EXPECT-- +int(43) +float(6.28) +bool(false) +int(2) +int(43) +int(43) +float(4.140000000000001)