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/5] 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/5] 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/5] 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) From e303b9b41d0e122cbe11bbb61fa8f0a4eb116b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Fri, 11 Sep 2026 09:26:26 +0800 Subject: [PATCH 4/5] feat(closure): rewrite closure parameter type narrowing v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign closure parameter type narrowing based on official review feedback: 1. Type declaration authoritative - resolveEffectiveClosureParamType() checks resolveTypeDecl() first, then inferred type, then VAR 2. Reuse compiler's detectTypeOfExpr() - deleted duplicate detectArgType() from LocalClosureAnalyzer, inferParamTypesFromCallSites() wraps the canonical type resolver 3. Operator result types - spaceship returns VAR (not in switch), unary-bool guard intercepts BOOL + UnaryMinus/Plus → VAR 4. Multi-call narrowing - inferParamTypesFromCallSites() checks ALL call sites agree; disagree → VAR 5. Nullable/Union/Intersection - always returns VAR with runtime check Files modified: - src/Generator/ClosureGenerator.php: new resolveEffectiveClosureParamType(), inferParamTypesFromCallSites(), inferCallSiteArgType() - src/Analysis/LocalClosureAnalyzer.php: deleted detectArgType(), inferParamTypes() - src/Translator.php: removed inferParamTypes loop - src/Context/FunctionContext.php: removed inferredParamTypes docblock - phpunit/code/closure-param-type.php: 47 isolated fixture functions - phpunit/src/ClosureParamTypeTest.php: 26 tests, 88 assertions --- phpunit/code/closure-param-type.php | 652 ++++++++++++++++++++++---- phpunit/src/ClosureParamTypeTest.php | 222 +++++++-- src/Analysis/LocalClosureAnalyzer.php | 125 ----- src/Context/FunctionContext.php | 1 - src/Generator/ClosureGenerator.php | 102 +++- src/Translator.php | 3 - 6 files changed, 815 insertions(+), 290 deletions(-) diff --git a/phpunit/code/closure-param-type.php b/phpunit/code/closure-param-type.php index c27d8bdf..ff9f03e3 100644 --- a/phpunit/code/closure-param-type.php +++ b/phpunit/code/closure-param-type.php @@ -2,185 +2,639 @@ /** * Test fixture for closure parameter type narrowing. * - * Tests: - * 1. Type declaration inference (int, float, bool, string, array) - * 2. Call-site literal inference (int, float, bool, array) - * 3. Multi-call fallback to php::Var - * 4. UnaryMinus/UnaryPlus expressions - * 5. Boolean expressions (===, ||, instanceof) - * 6. String concatenation - * 7. goto invalidates candidates - * 8. Nested functions (still narrowed) - * 9. Cast expressions + * Each function isolates one scenario. Parameter names are unique to avoid + * substring-match ambiguity in assertions. */ -// --- Type declaration inference --- -function closureTypeHintInt(int $x): int +// --- Type declaration narrowing (type decl takes priority) --- + +function typeDeclVarInt(int $v): int +{ + $fn = fn(int $p) => $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 $a) => $a + 1; - return $fn($x); + $fn = fn(int $q) => $q + 1; + return $fn(42); } -function closureTypeHintFloat(float $x): float +function typeDeclLitFloat(): float { - $fn = fn(float $a) => $a * 2.0; - return $fn($x); + $fn = fn(float $q) => $q * 2.0; + return $fn(3.14); } -function closureTypeHintBool(bool $x): bool +function typeDeclLitString(): int { - $fn = fn(bool $a) => !$a; - return $fn($x); + $fn = fn(string $q) => strlen($q); + return $fn("hello"); } -function closureTypeHintString(string $x): int +function typeDeclLitBool(): bool { - $fn = fn(string $a) => strlen($a); - return $fn($x); + $fn = fn(bool $q) => !$q; + return $fn(true); } -function closureTypeHintArray(array $x): int +// --- Type declaration wins over call-site inference --- + +function typeDeclWinsOverInfer(): int { - $fn = fn(array $a) => count($a); - return $fn($x); + $fn = fn(int $r) => $r + 1; + $s = "not an int"; + return $fn($s); } -// --- Call-site literal inference --- -function closureCallSiteInt(): int +// --- Call-site literal inference (no type declaration) --- + +function callSiteInt(): int { - $fn = fn($x) => $x + 1; + $fn = fn($s1) => $s1 + 1; return $fn(42); } -function closureCallSiteFloat(): float +function callSiteFloat(): float { - $fn = fn($x) => $x * 2.0; + $fn = fn($s2) => $s2 * 2.0; return $fn(3.14); } -function closureCallSiteBool(): bool +function callSiteBool(): bool { - $fn = fn($x) => !$x; + $fn = fn($s3) => !$s3; return $fn(true); } -function closureCallSiteArray(): int +function callSiteArray(): int { - $fn = fn($arr) => count($arr); - return $fn([1, 2, 3, 4, 5]); + $fn = fn($s4) => count($s4); + return $fn([1, 2, 3]); } -// --- Multi-call fallback --- -function closureMultiCallNoInfer(): void +// --- Multi-call fallback (all call sites disagree) --- + +function multiCallFallback(): void { - $fn = fn($x) => $x + 1; + $fn = fn($m1) => $m1 + 1; var_dump($fn(42)); var_dump($fn(3.14)); } -// --- UnaryMinus/UnaryPlus --- -function closureCallSiteNegInt(): int +// --- Unary expressions --- + +function unaryNegInt(): int { - $fn = fn($x) => $x + 1; + $fn = fn($u1) => $u1 + 1; return $fn(-42); } -function closureCallSiteNegFloat(): float +function unaryNegFloat(): float { - $fn = fn($x) => $x * 2.0; + $fn = fn($u2) => $u2 * 2.0; return $fn(-3.14); } -function closureCallSiteUnaryPlus(): int +function unaryPlus(): int { - $fn = fn($x) => $x + 1; + $fn = fn($u3) => $u3 + 1; return $fn(+42); } -// --- Boolean expressions --- -function closureCallSiteBoolExpr(): bool +// --- Cast expressions --- + +function castInt(): int { - $fn = fn($x) => !$x; - return $fn(1 === 2); + $fn = fn($c1) => $c1 + 1; + return $fn((int)"42"); } -function closureCallSiteLogicalOr(): bool +function castString(): string { - $fn = fn($x) => $x; - return $fn(true || false); + $fn = fn($c2) => $c2; + return $fn((string)42); } -function closureCallSiteInstanceof(): bool +function castFloat(): float { - $fn = fn($x) => $x; - return $fn(new \stdClass() instanceof \stdClass); + $fn = fn($c3) => $c3 * 2.0; + return $fn((float)"3.14"); } -// --- String concatenation --- -function closureCallSiteConcat(): string +function castBool(): bool { - $fn = fn($x) => $x; - return $fn("hello" . "world"); + $fn = fn($c4) => !$c4; + return $fn((bool)1); } -function closureCallSiteConcatWithInt(): string +// --- goto invalidates candidates --- + +function gotoInvalidates(): void { - $fn = fn($x) => $x; - return $fn("hello" . 42); + $fn = fn($g1) => $g1 + 1; + var_dump($fn(1)); + goto end; + end: } -// --- Cast expressions --- -function closureCallSiteCastInt(): int +// --- Spaceship operator (returns int, not bool) --- + +function spaceshipReturnsVar(): void { - $fn = fn($x) => $x + 1; - return $fn((int)"42"); + $fn = fn($sp1) => $sp1; + var_dump($fn(1 <=> 2)); } -function closureCallSiteCastString(): string +// --- Unary on bool operand (should stay VAR, not bool) --- + +function unaryNegBool(): void { - $fn = fn($x) => $x; - return $fn((string)42); + $fn = fn($un1) => $un1; + var_dump($fn(-true)); } -// --- goto invalidates candidates --- -function closureWithGoto(): void +// --- 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($x) => $x + 1; + $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)); - goto end; - end: + 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 closureWithNestedFn(): int + +function nestedFnStillNarrowed(): int { - $fn = fn($x) => $x + 1; + $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); +} + // --- 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(); + 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(); } diff --git a/phpunit/src/ClosureParamTypeTest.php b/phpunit/src/ClosureParamTypeTest.php index 4fe32297..33743a1e 100644 --- a/phpunit/src/ClosureParamTypeTest.php +++ b/phpunit/src/ClosureParamTypeTest.php @@ -26,137 +26,265 @@ private function compileFixture(string $fixture): string return file_get_contents($compiler->convertFile($source)); } - public function testTypeHintIntInfersNativeType(): void + // --- Type declaration narrows to native type (variable args) --- + + public function testTypeDeclVarNarrowsToNativeType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Int x)', $code); + 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); } - public function testTypeHintFloatInfersNativeType(): void + // --- Type declaration + literal args --- + + public function testTypeDeclLitUsesNativeType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Float x)', $code); + 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); } - public function testTypeHintBoolInfersNativeType(): void + // --- Type declaration wins over call-site inference --- + + public function testTypeDeclWinsOverInferInt(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Bool x)', $code); + self::assertMatchesRegularExpression('/php_typedeclwinsoverinfer\(.*?\n\tauto fn = \[\]\(php::Int r\)/s', $code); + self::assertStringContainsString('toIntArgExact', $code); } - public function testTypeHintStringInfersNativeType(): void + // --- Call-site literal inference --- + + public function testCallSiteInference(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Str x)', $code); + 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); } - public function testTypeHintArrayInfersNativeType(): void + // --- Unary expressions --- + + public function testUnaryInfersNativeType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Array x)', $code); + 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); } - public function testCallSiteIntLiteralInference(): void + // --- Cast expressions --- + + public function testCastInfersNativeType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Int x)', $code); + 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); } - public function testCallSiteFloatLiteralInference(): void + // --- Binary operators --- + + public function testBinaryOpInfersNativeType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Float x)', $code); + 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::Int 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); } - public function testCallSiteBoolLiteralInference(): void + // --- Bitwise / boolean operators --- + + public function testBitwiseBooleanInfersNativeType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Bool x)', $code); + 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); } - public function testCallSiteArrayLiteralInference(): void + // --- Comparison operators --- + + public function testComparisonInfersBool(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Array x)', $code); + 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); } - public function testMultiCallFallsBackToVar(): void + // --- Expression arguments --- + + public function testExprArgsInferType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Var x)', $code); + 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); } - public function testNegIntInference(): void + // --- Const fetch --- + + public function testConstFetchInfersType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Int x)', $code); + 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); } - public function testNegFloatInference(): void + // --- Multi-param closures --- + + public function testMultiParamInference(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Float x)', $code); + 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); } - public function testUnaryPlusInference(): void + // --- Multi-call same type narrows --- + + public function testMultiCallSameTypeNarrows(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Int x)', $code); + 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); } - public function testBoolExprInference(): void + // --- Multi-call disagree → VAR --- + + public function testMultiCallFallback(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Bool x)', $code); + // 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); } - public function testLogicalOrInference(): void + // --- Null / edge cases --- + + public function testNullAndEdgeCases(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Bool x)', $code); + 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); } - public function testInstanceofInference(): void + // ===== Unique scenario tests (each verifies a distinct behavior) ===== + + public function testGotoInvalidatesAllCandidates(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Bool x)', $code); + self::assertStringContainsString('newClosureWithParameters', $code); } - public function testConcatStringInference(): void + public function testNestedFnStillNarrowed(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Str x)', $code); + self::assertMatchesRegularExpression('/php_nestedfnstillnarrowed\(.*?\n\tauto fn = \[\]\(php::Int n1\)/s', $code); } - public function testConcatWithNonStringOperandInfersString(): void + 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::assertStringContainsString('(php::Str x)', $code); + self::assertMatchesRegularExpression('/php_spaceshipreturnsvar\(.*?\n\tauto fn = \[\]\(php::Var sp1\)/s', $code); + self::assertStringNotContainsString('(php::Bool sp1)', $code); } - public function testCastExpressionsInferNativeType(): void + public function testUnaryNegBoolDoesNotNarrowToBool(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Int x)', $code); - self::assertStringContainsString('(php::Str x)', $code); + self::assertMatchesRegularExpression('/php_unarynegbool\(.*?\n\tauto fn = \[\]\(php::Var un1\)/s', $code); + self::assertStringNotContainsString('(php::Bool un1)', $code); } - public function testGotoInvalidatesAllCandidates(): void + public function testDecimalLiteralInfersDecimalType(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('newClosureWithParameters', $code); + self::assertMatchesRegularExpression('/php_decimalliteralinfersdecimal\(.*?\n\tauto fn = \[\]\(php::Decimal dl1\)/s', $code); + self::assertStringNotContainsString('(php::Float dl1)', $code); } - public function testNestedFnStillWorks(): void + // --- Multi-param type decl mismatch --- + + public function testMultiParamTypeDeclMismatch(): void { $code = $this->compileFixture('closure-param-type.php'); - self::assertStringContainsString('(php::Int x)', $code); + self::assertMatchesRegularExpression('/php_multiparamtypedeclmismatch\(.*?\n\tauto fn = \[\]\(php::Int mt1, php::Str mt2\)/s', $code); + self::assertStringContainsString('toIntArgExact', $code); + self::assertStringContainsString('toStringArgExact', $code); } - public function testClassMethodClosureStaysZend(): void + // --- Ternary mixed branches → VAR --- + + public function testTernaryMixedBranchesInfersVar(): void { - $code = $this->compileFixture('closure-param-type-class.php'); - self::assertStringNotContainsString('(php::Int x)', $code); - self::assertStringContainsString('newClosureWithParameters', $code); + $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); } } diff --git a/src/Analysis/LocalClosureAnalyzer.php b/src/Analysis/LocalClosureAnalyzer.php index b9ec6cfe..5eeead5f 100644 --- a/src/Analysis/LocalClosureAnalyzer.php +++ b/src/Analysis/LocalClosureAnalyzer.php @@ -12,7 +12,6 @@ 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 @@ -228,128 +227,4 @@ 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 3fe88f3e..cb380aba 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -82,7 +82,6 @@ class FunctionContext * closure: \PhpParser\Node\Expr\Closure|\PhpParser\Node\Expr\ArrowFunction, * calls: int, * callSites: list<\PhpParser\Node\Expr\FuncCall>, - * inferredParamTypes: list * }> */ public array $localClosureCandidates = []; diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index cde25373..67acb0d8 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -130,8 +130,8 @@ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?stri $entryIndent = $this->indentLevel; $entryInGeneratorBody = $this->inGeneratorBody; - // Get inferred types from call sites - $inferredTypes = $candidate['inferredParamTypes'] ?? array_fill(0, count($expr->params), Type::VAR); + // Infer parameter types from call sites using compiler's type analysis + $inferredTypes = $this->inferParamTypesFromCallSites($candidate); $parameters = []; foreach ($expr->params as $i => $param) { @@ -284,9 +284,7 @@ private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $ 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. + // 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 ''; } @@ -309,16 +307,90 @@ private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $ } /** - * 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. + * 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 { - return $inferredType; + 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,] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + if ($declaredType !== 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 $siteTypes) { + if (($siteTypes[$i] ?? Type::VAR) !== $firstType) { + $agree = false; + break; + } + } + $result[$i] = $agree ? $firstType : Type::VAR; + } + return $result; + } + + /** + * Detect the native type for a call-site argument, handling edge cases + * that detectTypeOfExpr does not cover for closure narrowing purposes: + * - Unary +/- on bool: PHP coerces bool to int first, result is never bool. + */ + private function inferCallSiteArgType(Expr $expr): string + { + $type = $this->detectTypeOfExpr($expr); + + // -true / +false: PHP coerces bool to int before negation. + // detectTypeOfExpr returns BOOL (inherits operand type), but the + // actual runtime result is int, so we must not narrow to bool. + if ($type === Type::BOOL && ($expr instanceof Expr\UnaryMinus || $expr instanceof Expr\UnaryPlus)) { + return Type::VAR; + } + + return $type; } protected function parseNativeLocalClosureCall(Expr\FuncCall $expr, string $name): ?string @@ -333,7 +405,7 @@ protected function parseNativeLocalClosureCall(Expr\FuncCall $expr, string $name return null; } $closure = $candidate['closure'] ?? null; - $inferredTypes = $candidate['inferredParamTypes'] ?? []; + $inferredTypes = $this->inferParamTypesFromCallSites($candidate); $arguments = []; $forceMaterialize = count($expr->args) > 1; @@ -362,8 +434,8 @@ protected function parseNativeLocalClosureCall(Expr\FuncCall $expr, string $name } $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). + // 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) { diff --git a/src/Translator.php b/src/Translator.php index 22b3cefd..1ed6a2ee 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -5092,9 +5092,6 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): if ($v->stmts && !$this->class && $this->methodDef === null) { $analyzer = new LocalClosureAnalyzer(); $this->context->localClosureCandidates = $analyzer->analyze($v->stmts); - foreach ($this->context->localClosureCandidates as $closureName => &$candidate) { - $candidate['inferredParamTypes'] = $analyzer->inferParamTypes($candidate); - } } $stmts = ''; From 5ecb725d40043ff103b3ae602af736dfe03bc594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=9F=E7=82=B9?= <467490186@qq.com> Date: Fri, 11 Sep 2026 10:01:37 +0800 Subject: [PATCH 5/5] fix(closure): rename loop variable to avoid type conflict in self-compilation The foreach variable was reused across two scopes with different inferred types (php::Array from init, php::Var from foreach iteration), causing TypePHP's self-compilation type checker to reject the assignment. Rename to to eliminate the conflict. --- src/Generator/ClosureGenerator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index 67acb0d8..d7ee221a 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -363,8 +363,8 @@ private function inferParamTypesFromCallSites(array $candidate): array for ($i = 0; $i < $paramCount; $i++) { $firstType = $allTypes[0][$i] ?? Type::VAR; $agree = true; - foreach ($allTypes as $siteTypes) { - if (($siteTypes[$i] ?? Type::VAR) !== $firstType) { + foreach ($allTypes as $perSite) { + if (($perSite[$i] ?? Type::VAR) !== $firstType) { $agree = false; break; }