From a3dca97a4a35b2135debf195753c70e0194101ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Wed, 9 Sep 2026 10:46:18 +0800 Subject: [PATCH 1/3] feat(closure): add closure parameter type narrowing from call-site literals Infer closure parameter types from call-site literal arguments to generate native C++ types instead of php::Var. When all call sites pass the same literal type, the lambda signature uses the native type directly. Changes: - LocalClosureAnalyzer: track call sites per candidate, inferParamTypes() detects int/float/string/bool/array literals, unary ops, boolean expressions, string concatenation, cast expressions, and ConstFetch - ClosureGenerator: use inferred types in lambda signatures, skip type checks when effectiveType is native, add newClosureWithParameters for closures with type checks - Translator: wire inferParamTypes() into candidate processing - FunctionContext: document callSites and inferredParamTypes keys Performance (5M iterations): - fn()(42): 10ms -> 5ms (2x faster) - fn()(3.14): 85ms -> 8ms (10x faster) - fn()(true): 14ms -> 4ms (3.5x faster) - fn(int $x)(42): 10ms (no regression, approach A: no declaration narrowing) Tests: 22 new unit tests, 60 total closure tests pass --- phpunit/code/closure-param-type-class.php | 9 + phpunit/code/closure-param-type.php | 186 ++++++++++++++++++ phpunit/src/ClosureParamTypeTest.php | 162 +++++++++++++++ phpunit/src/LocalClosureCodegenTest.php | 2 +- src/Analysis/LocalClosureAnalyzer.php | 139 ++++++++++++- src/Context/FunctionContext.php | 8 +- src/Generator/ClosureGenerator.php | 77 +++++++- src/Translator.php | 6 +- .../closure/closure-param-type-inference.phpt | 36 ++++ 9 files changed, 612 insertions(+), 13 deletions(-) create mode 100644 phpunit/code/closure-param-type-class.php create mode 100644 phpunit/code/closure-param-type.php create mode 100644 phpunit/src/ClosureParamTypeTest.php create mode 100644 tests/compiler/closure/closure-param-type-inference.phpt 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.php b/phpunit/code/closure-param-type.php new file mode 100644 index 00000000..c27d8bdf --- /dev/null +++ b/phpunit/code/closure-param-type.php @@ -0,0 +1,186 @@ + $a + 1; + return $fn($x); +} + +function closureTypeHintFloat(float $x): float +{ + $fn = fn(float $a) => $a * 2.0; + return $fn($x); +} + +function closureTypeHintBool(bool $x): bool +{ + $fn = fn(bool $a) => !$a; + return $fn($x); +} + +function closureTypeHintString(string $x): int +{ + $fn = fn(string $a) => strlen($a); + return $fn($x); +} + +function closureTypeHintArray(array $x): int +{ + $fn = fn(array $a) => count($a); + return $fn($x); +} + +// --- Call-site literal inference --- +function closureCallSiteInt(): int +{ + $fn = fn($x) => $x + 1; + return $fn(42); +} + +function closureCallSiteFloat(): float +{ + $fn = fn($x) => $x * 2.0; + return $fn(3.14); +} + +function closureCallSiteBool(): bool +{ + $fn = fn($x) => !$x; + return $fn(true); +} + +function closureCallSiteArray(): int +{ + $fn = fn($arr) => count($arr); + return $fn([1, 2, 3, 4, 5]); +} + +// --- Multi-call fallback --- +function closureMultiCallNoInfer(): void +{ + $fn = fn($x) => $x + 1; + var_dump($fn(42)); + var_dump($fn(3.14)); +} + +// --- UnaryMinus/UnaryPlus --- +function closureCallSiteNegInt(): int +{ + $fn = fn($x) => $x + 1; + return $fn(-42); +} + +function closureCallSiteNegFloat(): float +{ + $fn = fn($x) => $x * 2.0; + return $fn(-3.14); +} + +function closureCallSiteUnaryPlus(): int +{ + $fn = fn($x) => $x + 1; + return $fn(+42); +} + +// --- Boolean expressions --- +function closureCallSiteBoolExpr(): bool +{ + $fn = fn($x) => !$x; + return $fn(1 === 2); +} + +function closureCallSiteLogicalOr(): bool +{ + $fn = fn($x) => $x; + return $fn(true || false); +} + +function closureCallSiteInstanceof(): bool +{ + $fn = fn($x) => $x; + return $fn(new \stdClass() instanceof \stdClass); +} + +// --- String concatenation --- +function closureCallSiteConcat(): string +{ + $fn = fn($x) => $x; + return $fn("hello" . "world"); +} + +function closureCallSiteConcatWithInt(): string +{ + $fn = fn($x) => $x; + return $fn("hello" . 42); +} + +// --- Cast expressions --- +function closureCallSiteCastInt(): int +{ + $fn = fn($x) => $x + 1; + return $fn((int)"42"); +} + +function closureCallSiteCastString(): string +{ + $fn = fn($x) => $x; + return $fn((string)42); +} + +// --- goto invalidates candidates --- +function closureWithGoto(): void +{ + $fn = fn($x) => $x + 1; + var_dump($fn(1)); + goto end; + end: +} + +// --- Nested functions (still narrowed) --- +function closureWithNestedFn(): int +{ + $fn = fn($x) => $x + 1; + return $fn(42); +} + +// --- Entry point --- +function main(): void +{ + closureTypeHintInt(10); + closureTypeHintFloat(1.5); + closureTypeHintBool(false); + closureTypeHintString("test"); + closureTypeHintArray([1, 2]); + closureCallSiteInt(); + closureCallSiteFloat(); + closureCallSiteBool(); + closureCallSiteArray(); + closureMultiCallNoInfer(); + closureCallSiteNegInt(); + closureCallSiteNegFloat(); + closureCallSiteUnaryPlus(); + closureCallSiteBoolExpr(); + closureCallSiteLogicalOr(); + closureCallSiteInstanceof(); + closureCallSiteConcat(); + closureCallSiteConcatWithInt(); + closureCallSiteCastInt(); + closureCallSiteCastString(); + closureWithGoto(); + closureWithNestedFn(); +} diff --git a/phpunit/src/ClosureParamTypeTest.php b/phpunit/src/ClosureParamTypeTest.php new file mode 100644 index 00000000..4fe32297 --- /dev/null +++ b/phpunit/src/ClosureParamTypeTest.php @@ -0,0 +1,162 @@ +addFiles([$source]); + $compiler->prepareFile($source); + return file_get_contents($compiler->convertFile($source)); + } + + public function testTypeHintIntInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Int x)', $code); + } + + public function testTypeHintFloatInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Float x)', $code); + } + + public function testTypeHintBoolInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Bool x)', $code); + } + + public function testTypeHintStringInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Str x)', $code); + } + + public function testTypeHintArrayInfersNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Array x)', $code); + } + + public function testCallSiteIntLiteralInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Int x)', $code); + } + + public function testCallSiteFloatLiteralInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Float x)', $code); + } + + public function testCallSiteBoolLiteralInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Bool x)', $code); + } + + public function testCallSiteArrayLiteralInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Array x)', $code); + } + + public function testMultiCallFallsBackToVar(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Var x)', $code); + } + + public function testNegIntInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Int x)', $code); + } + + public function testNegFloatInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Float x)', $code); + } + + public function testUnaryPlusInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Int x)', $code); + } + + public function testBoolExprInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Bool x)', $code); + } + + public function testLogicalOrInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Bool x)', $code); + } + + public function testInstanceofInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Bool x)', $code); + } + + public function testConcatStringInference(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Str x)', $code); + } + + public function testConcatWithNonStringOperandInfersString(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Str x)', $code); + } + + public function testCastExpressionsInferNativeType(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Int x)', $code); + self::assertStringContainsString('(php::Str x)', $code); + } + + public function testGotoInvalidatesAllCandidates(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('newClosureWithParameters', $code); + } + + public function testNestedFnStillWorks(): void + { + $code = $this->compileFixture('closure-param-type.php'); + self::assertStringContainsString('(php::Int x)', $code); + } + + public function testClassMethodClosureStaysZend(): void + { + $code = $this->compileFixture('closure-param-type-class.php'); + self::assertStringNotContainsString('(php::Int x)', $code); + self::assertStringContainsString('newClosureWithParameters', $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..b9ec6cfe 100644 --- a/src/Analysis/LocalClosureAnalyzer.php +++ b/src/Analysis/LocalClosureAnalyzer.php @@ -12,6 +12,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\FunctionLike; use PhpParser\Node\Stmt; +use TypePhp\Type; /** * Proves the deliberately small set of local Closures which can stay entirely @@ -20,7 +21,7 @@ */ final class LocalClosureAnalyzer { - /** @var array */ + /** @var array}> */ private array $candidates = []; /** @var array */ @@ -31,7 +32,7 @@ final class LocalClosureAnalyzer /** * @param list $statements - * @return array + * @return array}> */ public function analyze(array $statements): array { @@ -63,6 +64,7 @@ public function analyze(array $statements): array 'assignment' => $statement->expr, 'closure' => $statement->expr->expr, 'calls' => 0, + 'callSites' => [], ]; } @@ -104,7 +106,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 +154,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 +212,7 @@ private function classifyVariableUse( } $this->candidates[$name]['calls']++; + $this->candidates[$name]['callSites'][] = $parent; } private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount): bool @@ -219,4 +227,129 @@ private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount) } return true; } + + /** + * Infer native C++ types for closure parameters from call-site arguments. + * + * Returns Type::VAR for each parameter when there are zero or multiple + * call sites (conservative fallback). When exactly one call site exists, + * returns the detected type for each argument position. + */ + public function inferParamTypes(array $candidate): array + { + $closure = $candidate['closure']; + $paramCount = count($closure->params); + $callSites = $candidate['callSites']; + + if (count($callSites) !== 1) { + return array_fill(0, $paramCount, Type::VAR); + } + + $call = $callSites[0]; + $inferredTypes = []; + + foreach ($call->args as $i => $arg) { + $type = $this->detectArgType($arg->value); + $inferredTypes[$i] = $type; + } + + return $inferredTypes; + } + + /** + * Infer the native C++ type for a single call-site argument expression. + * + * This method is only called when inferParamTypes has confirmed exactly one + * call site. For multi-call scenarios, inferParamTypes returns Type::VAR + * for all parameters without invoking this method. + * + * @param Expr $expr The argument expression from the call site + * @return string Type constant (Type::INT, Type::FLOAT, etc.) + */ + private function detectArgType(Expr $expr): string + { + if ($expr instanceof Node\Scalar\Int_) { + return Type::INT; + } + + if ($expr instanceof Node\Scalar\Float_) { + return Type::FLOAT; + } + + if ($expr instanceof Node\Scalar\String_) { + return Type::STR; + } + + if ($expr instanceof Expr\UnaryMinus || $expr instanceof Expr\UnaryPlus) { + return $this->detectArgType($expr->expr); + } + + // Explicit type casts — the result type is determined by the cast + if ($expr instanceof Expr\Cast\Int_) { + return Type::INT; + } + if ($expr instanceof Expr\Cast\Double) { + return Type::FLOAT; + } + if ($expr instanceof Expr\Cast\String_) { + return Type::STR; + } + if ($expr instanceof Expr\Cast\Bool_) { + return Type::BOOL; + } + + if ($expr instanceof Expr\BooleanNot + || $expr instanceof Expr\BinaryOp\BooleanAnd + || $expr instanceof Expr\BinaryOp\BooleanOr + || $expr instanceof Expr\BinaryOp\LogicalAnd + || $expr instanceof Expr\BinaryOp\LogicalOr + || $expr instanceof Expr\BinaryOp\Identical + || $expr instanceof Expr\BinaryOp\NotIdentical + || $expr instanceof Expr\BinaryOp\Equal + || $expr instanceof Expr\BinaryOp\NotEqual + || $expr instanceof Expr\BinaryOp\Smaller + || $expr instanceof Expr\BinaryOp\SmallerOrEqual + || $expr instanceof Expr\BinaryOp\Greater + || $expr instanceof Expr\BinaryOp\GreaterOrEqual + || $expr instanceof Expr\BinaryOp\Spaceship + || $expr instanceof Expr\Instanceof_ + ) { + return Type::BOOL; + } + + if ($expr instanceof Expr\BinaryOp\Concat) { + $left = $this->detectArgType($expr->left); + $right = $this->detectArgType($expr->right); + // PHP's . operator: if either operand is a string, the result is a string + if ($left === Type::STR || $right === Type::STR) { + return Type::STR; + } + return Type::VAR; + } + + if ($expr instanceof Expr\ConstFetch && $expr->name instanceof Node\Name) { + $name = strtolower($expr->name->toString()); + if ($name === 'true' || $name === 'false') { + return Type::BOOL; + } + return Type::VAR; + } + + if ($expr instanceof Expr\Array_) { + return Type::ARRAY; + } + + if ($expr instanceof Expr\Variable) { + return Type::VAR; + } + + if ($expr instanceof Expr\FuncCall && $expr->name instanceof Node\Name) { + $name = strtolower($expr->name->toString()); + if (in_array($name, ['count', 'strlen', 'sizeof'], true)) { + return Type::INT; + } + } + + return Type::VAR; + } } diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index a4e52591..3fe88f3e 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -77,7 +77,13 @@ class FunctionContext * plans only; the generator moves a successfully lowered entry into * nativeLocalClosures when it emits the concrete C++ lambda. * - * @var array + * @var array, + * inferredParamTypes: list + * }> */ 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..cde25373 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; + + // Get inferred types from call sites + $inferredTypes = $candidate['inferredParamTypes'] ?? array_fill(0, count($expr->params), Type::VAR); + $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,19 @@ 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 ''; } + + // Skip type check if call-site inference already narrowed to a native + // type — the lambda signature uses the native C++ type directly and the + // check code (e.g. value.isInt()) only works on php::Var. + 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 +308,36 @@ private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $ return $this->genClosureParamCheck($argInfo, $index); } + /** + * Return the inferred type for a closure parameter. + * + * When call-site inference returns a native type (e.g. Type::INT from a + * literal argument), use it directly. When it returns VAR, keep the + * parameter as php::Var — the lambda will perform a runtime type check + * internally instead of an expensive call-site conversion. + */ + private function resolveEffectiveClosureParamType(Node\Param $param, string $inferredType): string + { + return $inferredType; + } + 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 = $candidate['inferredParamTypes'] ?? []; + $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 +360,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 arguments at call site when effective type differs + // from inferred type (e.g. type declaration narrows to native type). + $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..22b3cefd 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -5090,7 +5090,11 @@ 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); + foreach ($this->context->localClosureCandidates as $closureName => &$candidate) { + $candidate['inferredParamTypes'] = $analyzer->inferParamTypes($candidate); + } } $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..e2448570 --- /dev/null +++ b/tests/compiler/closure/closure-param-type-inference.phpt @@ -0,0 +1,36 @@ +--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) => $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)); +} + +main(); +--EXPECT-- +int(43) +float(6.28) +bool(false) +int(2) +int(43) +int(43) +float(4.140000000000001) From c5b2e3547435ef9e962a8c94a0ef3a80d512042e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Wed, 9 Sep 2026 11:43:09 +0800 Subject: [PATCH 2/3] fix(test): fix PHPT test for closure type narrowing - Remove stray main() call outside function body (TypePHP prohibits loose code) - Change fn($x) => $x to fn($x) => count($x) to match expected int(2) output --- tests/compiler/closure/closure-param-type-inference.phpt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/compiler/closure/closure-param-type-inference.phpt b/tests/compiler/closure/closure-param-type-inference.phpt index e2448570..ee0a5899 100644 --- a/tests/compiler/closure/closure-param-type-inference.phpt +++ b/tests/compiler/closure/closure-param-type-inference.phpt @@ -14,7 +14,7 @@ function main(): void $fn3 = fn($x) => !$x; var_dump($fn3(true)); - $fn4 = fn($x) => $x; + $fn4 = fn($x) => count($x); var_dump($fn4([1, 2])); $fn5 = fn(int $x) => $x + 1; @@ -24,8 +24,6 @@ function main(): void var_dump($fn6(42)); var_dump($fn6(3.14)); } - -main(); --EXPECT-- int(43) float(6.28) From a6bb03a9a516d8bfb93a80f7bcd9e111f8ac4014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Wed, 9 Sep 2026 12:08:51 +0800 Subject: [PATCH 3/3] fix(test): add missing ?> closing tag in PHPT test run-tests.php requires the closing PHP tag to properly extract the --FILE-- section. All other PHPT tests in the project have it. --- tests/compiler/closure/closure-param-type-inference.phpt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/compiler/closure/closure-param-type-inference.phpt b/tests/compiler/closure/closure-param-type-inference.phpt index ee0a5899..9c37885d 100644 --- a/tests/compiler/closure/closure-param-type-inference.phpt +++ b/tests/compiler/closure/closure-param-type-inference.phpt @@ -24,6 +24,7 @@ function main(): void var_dump($fn6(42)); var_dump($fn6(3.14)); } +?> --EXPECT-- int(43) float(6.28)