diff --git a/phpunit/code/closure-param-type.php b/phpunit/code/closure-param-type.php new file mode 100644 index 00000000..d0ddf88c --- /dev/null +++ b/phpunit/code/closure-param-type.php @@ -0,0 +1,113 @@ + $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); +} +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]); +} +function closureMultiCallNoInfer(): void +{ + $fn = fn($x) => $x + 1; + var_dump($fn(42)); + var_dump($fn(3.14)); +} +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); +} +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); +} +function closureCallSiteConcat(): string +{ + $fn = fn($x) => $x; + return $fn("hello" . "world"); +} +function closureCallSiteConcatMixed(): string +{ + $fn = fn($x) => $x; + return $fn("hello" . 123); +} +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(); + closureCallSiteConcatMixed(); +} diff --git a/phpunit/src/ClosureParamTypeTest.php b/phpunit/src/ClosureParamTypeTest.php new file mode 100644 index 00000000..a32c19cc --- /dev/null +++ b/phpunit/src/ClosureParamTypeTest.php @@ -0,0 +1,110 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertIsString($code); + + self::assertStringContainsString('(php::Int a)', $code); + self::assertStringContainsString('(php::Float a)', $code); + self::assertStringContainsString('(php::Bool a)', $code); + self::assertStringContainsString('(php::Str a)', $code); + self::assertStringContainsString('(php::Array a)', $code); + } + + public function testCallSiteInferredParametersUseNativeCppTypes(): void + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/closure-param-type.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertIsString($code); + + self::assertStringContainsString('(php::Int x)', $code); + self::assertStringContainsString('(php::Float x)', $code); + self::assertStringContainsString('(php::Bool x)', $code); + self::assertStringContainsString('(php::Array arr)', $code); + } + + public function testMultiCallClosureRemainsPhpVar(): void + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/closure-param-type.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertIsString($code); + + self::assertStringContainsString('(php::Var x)', $code); + } + + public function testUnaryMinusInfersNativeType(): void + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/closure-param-type.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertIsString($code); + + self::assertStringContainsString('(php::Int x)', $code); + self::assertStringContainsString('(php::Float x)', $code); + } + + public function testBooleanExpressionsInferBoolType(): void + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/closure-param-type.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertIsString($code); + + self::assertStringContainsString('(php::Bool x)', $code); + } + + public function testConcatStringInference(): void + { + global $translator; + + $compiler = CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/closure-param-type.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $code = file_get_contents($compiler->convertFile($source)); + + self::assertIsString($code); + + self::assertStringContainsString('(php::Str x)', $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..a7f0b7d4 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' => [], ]; } @@ -205,6 +207,7 @@ private function classifyVariableUse( } $this->candidates[$name]['calls']++; + $this->candidates[$name]['callSites'][] = $parent; } private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount): bool @@ -219,4 +222,97 @@ private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount) } return true; } + + 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; + } + + 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); + } + + 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); + 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/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index e1f130eb..98db63cd 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -129,9 +129,22 @@ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?stri $entryContext = $this->context; $entryIndent = $this->indentLevel; $entryInGeneratorBody = $this->inGeneratorBody; + + $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) { + $paramType = $inferredTypes[$i] ?? Type::VAR; + + // Type declarations take priority over call-site inference + if ($param->type !== null) { + [$resolvedType,] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + // Only use native types (int/float/bool/string/array), keep object as VAR + if (in_array($resolvedType, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true)) { + $paramType = $resolvedType; + } + } + $parameters[] = $paramType . ' ' . $this->parseIdentifier($param->var); } $code = 'auto ' . $name . ' = [' . implode(', ', $capturePlan['cpp']) . '](' @@ -158,7 +171,14 @@ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?stri $parameterChecks = ''; foreach ($expr->params as $index => $param) { $paramName = $this->parseIdentifier($param->var); - $this->addArgument($paramName, Type::VAR); + $paramType = $inferredTypes[$index] ?? Type::VAR; + if ($param->type !== null) { + [$resolvedType,] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + if (in_array($resolvedType, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true)) { + $paramType = $resolvedType; + } + } + $this->addArgument($paramName, $paramType); if (CompileTimeAttribute::consume($param, 'Immutable')) { $this->context->immutableVars[$paramName] = true; if ($this->immutableTypeNodeMayBeObject($param->type)) { @@ -273,6 +293,16 @@ private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $ if ($param->type === null) { return ''; } + + // Skip type check if parameter is already a native type (not php::Var). + // When the parameter type is resolved to a native C++ type (int, float, + // bool, string, array), the value is already unwrapped at the ABI level + // and a Z_TYPE_P check against php::Int/Float/etc. would be meaningless. + [$resolvedType,] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + if (in_array($resolvedType, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR, Type::ARRAY], true)) { + return ''; + } + $typeInfo = $this->buildTypeCheckFromNode($param->type, true); if (empty($typeInfo['check'])) { return ''; 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..3d126c8f --- /dev/null +++ b/tests/compiler/closure/closure-param-type-inference.phpt @@ -0,0 +1,81 @@ +--TEST-- +Native local closure parameter types are inferred from type declarations and call-site literals +--FILE-- + $x + 1; + var_dump($fn(42)); +} +function testTypeHintFloat(): void +{ + $fn = fn(float $x) => $x * 2.0; + var_dump($fn(3.14)); +} +function testTypeHintBool(): void +{ + $fn = fn(bool $x) => !$x; + var_dump($fn(true)); +} +function testTypeHintString(): void +{ + $fn = fn(string $x) => strlen($x); + var_dump($fn("hello")); +} +function testTypeHintArray(): void +{ + $fn = fn(array $x) => count($x); + var_dump($fn([1, 2, 3])); +} +function testCallSiteInt(): void +{ + $fn = fn($x) => $x + 1; + var_dump($fn(42)); +} +function testCallSiteFloat(): void +{ + $fn = fn($x) => $x * 2.0; + var_dump($fn(3.14)); +} +function testCallSiteBool(): void +{ + $fn = fn($x) => !$x; + var_dump($fn(true)); +} +function testCallSiteArray(): void +{ + $fn = fn($arr) => count($arr); + var_dump($fn([1, 2, 3, 4, 5])); +} +function testMultiCallNoInfer(): void +{ + $fn = fn($x) => $x + 1; + var_dump($fn(42)); + var_dump($fn(3.14)); +} +function main(): void +{ + testTypeHintInt(); + testTypeHintFloat(); + testTypeHintBool(); + testTypeHintString(); + testTypeHintArray(); + testCallSiteInt(); + testCallSiteFloat(); + testCallSiteBool(); + testCallSiteArray(); + testMultiCallNoInfer(); +} +?> +--EXPECT-- +int(43) +float(6.28) +bool(false) +int(5) +int(3) +int(43) +float(6.28) +bool(false) +int(5) +int(43) +float(44)