diff --git a/CLAUDE.md b/CLAUDE.md index bf389d0a..89b2fe0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,20 @@ Swoole-Compiler is an AOT (Ahead-of-Time) compiler that translates PHP source co **Prerequisites**: PHP 8.2+, GCC 9+ (C++17), CMake 3.24+. The `swoole/phpx` extension must be compiled (see README.md). +## AOT Language Design Principles + +The AOT compiler should not blindly mirror every PHP language behavior. Most PHP syntax and semantics should remain compatible with ZendPHP, but some legal PHP constructs are historical baggage or language-design mistakes that conflict with static compilation, clear semantics, or robust generated C++ code. + +When reviewing or changing compiler behavior: + +- Prefer PHP compatibility for common, well-defined syntax that does not weaken the AOT static model. +- Reject PHP historical baggage when the syntax is ambiguous, surprising, or only preserved for legacy compatibility. +- Diagnose such cases as early as possible during preprocessing/static compilation, instead of deferring to runtime TypeCheck or ZendVM errors. +- Provide precise errors that include the relevant function/method name, parameter/property name, and type information where applicable. +- Compare with other statically compiled languages such as C/C++, Java, C#, Go, Rust, Kotlin, and TypeScript before deciding whether AOT should preserve or reject a PHP behavior. + +Example: `function test($a = 1, $b, $c) {}` is legal in PHP, but the default value for `$a` is effectively ignored and all parameters become required. This is a PHP historical compatibility artifact. AOT should reject it during preprocessing instead of preserving the behavior. + ## Build & Test Commands ```bash diff --git a/phpunit/code/clone-invalid-return-type.php b/phpunit/code/clone-invalid-return-type.php new file mode 100644 index 00000000..19392f3f --- /dev/null +++ b/phpunit/code/clone-invalid-return-type.php @@ -0,0 +1,13 @@ +exec("Type 'static' cannot be part of an intersection type", 'intersection_type_static_not_allowed.php'); } + + public function testConstructorCannotDeclareReturnType() + { + $this->exec('Method `ConstructorReturnType::__construct()` cannot declare a return type', 'constructor-return-type.php'); + } + + public function testDestructorCannotDeclareReturnType() + { + $this->exec('Method `DestructorReturnType::__destruct()` cannot declare a return type', 'destructor-return-type.php'); + } + + public function testCloneReturnTypeMustBeVoid() + { + $this->exec('Method `CloneInvalidReturnType::__clone()` return type must be void when declared', 'clone-invalid-return-type.php'); + } + + public function testCallMagicMethodCannotBeStatic() + { + $this->exec('Method MagicCallStaticInvalid::__call() cannot be static', 'magic-call-static.php'); + } + + public function testCallStaticMagicMethodMustBeStatic() + { + $this->exec('Method MagicCallStaticNonStaticInvalid::__callStatic() must be static', 'magic-callstatic-nonstatic.php'); + } + + public function testToStringMagicMethodCannotTakeArguments() + { + $this->exec('Method MagicToStringArgsInvalid::__toString() must take exactly 0 arguments', 'magic-tostring-args.php'); + } + + public function testSetStateMagicMethodMustBeStatic() + { + $this->exec('Method MagicSetStateNonStaticInvalid::__set_state() must be static', 'magic-set-state-nonstatic.php'); + } + + public function testDestructMagicMethodCannotTakeArguments() + { + $this->exec('Method MagicDestructArgsInvalid::__destruct() must take exactly 0 arguments', 'magic-destruct-args.php'); + } + + public function testGetMagicMethodParameterMustBeString() + { + $this->exec('Method MagicGetParamTypeInvalid::__get() must take string as argument', 'magic-get-param-type.php'); + } + + public function testMagicMethodMustBePublic() + { + $this->exec('Method MagicGetProtectedInvalid::__get() must have public visibility', 'magic-get-protected.php'); + } } diff --git a/phpunit/src/FunctionTest.php b/phpunit/src/FunctionTest.php index e98a3bae..0881e86c 100644 --- a/phpunit/src/FunctionTest.php +++ b/phpunit/src/FunctionTest.php @@ -57,4 +57,24 @@ class FunctionTest extends \BaseTest $this->exec('Cannot use positional argument after argument unpacking', 'new-positional-after-unpack.php'); } + public function testClosureReferenceParameter() + { + $this->exec('Closure cannot use reference parameter', 'closure-ref-param.php'); + } + + public function testVariadicReferenceParameter() + { + $this->exec('Variadic parameters cannot be passed by reference', 'variadic-ref-param.php'); + } + + public function testOptionalParameterBeforeRequiredParameter() + { + $this->exec('test(): optional parameter `$a` cannot be declared before required parameter `$c`', 'optional-before-required-param.php'); + } + + public function testMethodOptionalParameterBeforeRequiredParameter() + { + $this->exec('OptionalBeforeRequired::method(): optional parameter `$first` cannot be declared before required parameter `$second`', 'method-optional-before-required-param.php'); + } + } diff --git a/src/Php/Generator/ClosureGenerator.php b/src/Php/Generator/ClosureGenerator.php index 401a9523..60c4cc3f 100644 --- a/src/Php/Generator/ClosureGenerator.php +++ b/src/Php/Generator/ClosureGenerator.php @@ -58,10 +58,11 @@ trait ClosureGenerator $requiredArgCount++; } if ($requiredArgCount > 0) { + $expected = $requiredArgCount === count($params) ? 'exactly' : 'at least'; $message = 'php::concat({' . 'php::Str(' . $this->genCharPtr('Too few arguments to function {closure}(), ', true) . '), ' . 'php::toString(php::getCallArgNum()), ' - . 'php::Str(' . $this->genCharPtr(' passed and exactly ' . $requiredArgCount . ' expected', true) . ')' + . 'php::Str(' . $this->genCharPtr(' passed and ' . $expected . ' ' . $requiredArgCount . ' expected', true) . ')' . '})'; $code .= $this->getIndent() . 'if (UNEXPECTED(php::getCallArgNum() < ' . $requiredArgCount . ')) {' . PHP_EOL; $this->indentLevel++; diff --git a/src/Php/MagicMethodDetector.php b/src/Php/MagicMethodDetector.php index f7f8b2d6..469fc69a 100644 --- a/src/Php/MagicMethodDetector.php +++ b/src/Php/MagicMethodDetector.php @@ -20,130 +20,182 @@ trait MagicMethodDetector $returnTypeUndeclared = $fnDef->returnTypeUndeclared; $nameLower = strtolower($name); + $methodName = $this->class . "::{$name}"; + $isStatic = (bool) ($methodDef->flags & \PhpParser\Modifiers::STATIC); + + $mustBeStatic = ['__callstatic' => true, '__set_state' => true]; + $mustNotBeStatic = [ + '__construct' => true, + '__destruct' => true, + '__clone' => true, + '__call' => true, + '__get' => true, + '__set' => true, + '__isset' => true, + '__unset' => true, + '__sleep' => true, + '__wakeup' => true, + '__serialize' => true, + '__unserialize' => true, + '__debuginfo' => true, + '__tostring' => true, + '__invoke' => true, + ]; + if (isset($mustBeStatic[$nameLower]) && !$isStatic) { + $this->fatalError($v, 'Method ' . $methodName . '() must be static'); + } + if (isset($mustNotBeStatic[$nameLower]) && $isStatic) { + $this->fatalError($v, 'Method ' . $methodName . '() cannot be static'); + } + + $mustBePublic = [ + '__call' => true, + '__callstatic' => true, + '__get' => true, + '__set' => true, + '__isset' => true, + '__unset' => true, + '__sleep' => true, + '__wakeup' => true, + '__serialize' => true, + '__unserialize' => true, + '__debuginfo' => true, + '__tostring' => true, + '__invoke' => true, + '__set_state' => true, + ]; + if (isset($mustBePublic[$nameLower]) && !($methodDef->flags & \PhpParser\Modifiers::PUBLIC)) { + $this->fatalError($v, 'Method ' . $methodName . '() must have public visibility'); + } + + $exactArgCount = [ + '__destruct' => 0, + '__clone' => 0, + '__tostring' => 0, + '__sleep' => 0, + '__wakeup' => 0, + '__serialize' => 0, + '__debuginfo' => 0, + '__call' => 2, + '__callstatic' => 2, + '__set' => 2, + '__get' => 1, + '__isset' => 1, + '__unset' => 1, + '__set_state' => 1, + '__unserialize' => 1, + ]; + if (array_key_exists($nameLower, $exactArgCount) && count($argInfoList) !== $exactArgCount[$nameLower]) { + $this->fatalError($v, 'Method ' . $methodName . '() must take exactly ' . $exactArgCount[$nameLower] . ' arguments'); + } if ($nameLower == '__call' or $nameLower == '__callstatic') { - if (count($argInfoList) != 2) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 2 arguments"); - } if ($argInfoList[0]->undeclared) { $argInfoList[0]->type = self::TYPE_STR; } elseif ($argInfoList[0]->type !== self::TYPE_STR) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take string as first argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take string as first argument'); } if ($argInfoList[1]->undeclared) { $argInfoList[1]->type = self::TYPE_ARRAY; } elseif ($argInfoList[1]->type !== self::TYPE_ARRAY) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take array as second argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take array as second argument'); } } elseif ($nameLower == '__set') { - if (count($argInfoList) != 2) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 2 arguments"); - } if ($argInfoList[0]->undeclared) { $argInfoList[0]->type = self::TYPE_STR; } elseif ($argInfoList[0]->type !== self::TYPE_STR) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take string as first argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take string as first argument'); } if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_VOID; } elseif ($fnDef->returnType !== self::TYPE_VOID) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return void"); + $this->fatalError($v, 'Method ' . $methodName . '() must return void'); } } elseif ($nameLower == '__get') { - if (count($argInfoList) != 1) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 1 argument"); + if ($argInfoList[0]->undeclared) { + $argInfoList[0]->type = self::TYPE_STR; + } elseif ($argInfoList[0]->type !== self::TYPE_STR) { + $this->fatalError($v, 'Method ' . $methodName . '() must take string as argument'); } } elseif ($nameLower == '__tostring') { if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_STR; } elseif ($fnDef->returnType !== self::TYPE_STR) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return string"); + $this->fatalError($v, 'Method ' . $methodName . '() must return string'); } } elseif ($nameLower == '__serialize') { if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_ARRAY; } elseif ($fnDef->returnType !== self::TYPE_ARRAY) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return array"); + $this->fatalError($v, 'Method ' . $methodName . '() must return array'); } } elseif ($nameLower == '__unserialize') { - if (count($argInfoList) != 1) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 1 argument"); - } if ($argInfoList[0]->undeclared) { $argInfoList[0]->type = self::TYPE_ARRAY; } elseif ($argInfoList[0]->type !== self::TYPE_ARRAY) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take array as argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take array as argument'); } if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_VOID; } elseif ($fnDef->returnType !== self::TYPE_VOID) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return void"); + $this->fatalError($v, 'Method ' . $methodName . '() must return void'); } } elseif ($nameLower == '__isset') { - if (count($argInfoList) != 1) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 1 argument"); - } if ($argInfoList[0]->undeclared) { $argInfoList[0]->type = self::TYPE_STR; } elseif ($argInfoList[0]->type !== self::TYPE_STR) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take string as argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take string as argument'); } if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_BOOL; } elseif ($fnDef->returnType !== self::TYPE_BOOL) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return bool"); + $this->fatalError($v, 'Method ' . $methodName . '() must return bool'); } } elseif ($nameLower == '__unset') { - if (count($argInfoList) != 1) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 1 argument"); - } if ($argInfoList[0]->undeclared) { $argInfoList[0]->type = self::TYPE_STR; } elseif ($argInfoList[0]->type !== self::TYPE_STR) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take string as argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take string as argument'); } if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_VOID; } elseif ($fnDef->returnType !== self::TYPE_VOID) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return void"); + $this->fatalError($v, 'Method ' . $methodName . '() must return void'); } } elseif ($nameLower == '__set_state') { - if (count($argInfoList) != 1) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take exactly 1 argument"); - } if ($argInfoList[0]->undeclared) { $argInfoList[0]->type = self::TYPE_ARRAY; } elseif ($argInfoList[0]->type !== self::TYPE_ARRAY) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must take array as argument"); + $this->fatalError($v, 'Method ' . $methodName . '() must take array as argument'); } if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_OBJECT; } elseif ($fnDef->returnType !== self::TYPE_OBJECT) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return object"); + $this->fatalError($v, 'Method ' . $methodName . '() must return object'); } } elseif ($nameLower == '__debuginfo') { if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_ARRAY; } elseif ($fnDef->returnType !== self::TYPE_ARRAY) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return array"); + $this->fatalError($v, 'Method ' . $methodName . '() must return array'); } } elseif ($nameLower == '__sleep') { if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_ARRAY; } elseif ($fnDef->returnType !== self::TYPE_ARRAY) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return array"); + $this->fatalError($v, 'Method ' . $methodName . '() must return array'); } } elseif ($nameLower == '__wakeup') { if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_VOID; } elseif ($fnDef->returnType !== self::TYPE_VOID) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return void"); + $this->fatalError($v, 'Method ' . $methodName . '() must return void'); } } elseif ($nameLower == '__clone') { if ($returnTypeUndeclared) { $fnDef->returnType = self::TYPE_VOID; } elseif ($fnDef->returnType !== self::TYPE_VOID) { - $this->fatalError($v, 'Method ' . $this->class . "::{$name}() must return void"); + $this->fatalError($v, 'Method ' . $methodName . '() must return void'); } } diff --git a/src/Php/Preprocessor.php b/src/Php/Preprocessor.php index 0fce7e30..05a1988a 100644 --- a/src/Php/Preprocessor.php +++ b/src/Php/Preprocessor.php @@ -245,8 +245,17 @@ class Preprocessor extends CompilerBase { $list = []; $functionDef->argCountRequired = count($params); - $defaultValueCount = 0; + $lastRequiredIndex = -1; + $lastRequiredName = ''; $last = array_key_last($params); + foreach ($params as $i => $param) { + if (!$param->default && !$param->variadic) { + $lastRequiredIndex = $i; + if (is_string($param->var->name)) { + $lastRequiredName = $param->var->name; + } + } + } foreach ($params as $i => $param) { if (!is_string($param->var->name)) { @@ -276,6 +285,14 @@ class Preprocessor extends CompilerBase $this->fatalError($param, 'Variadic parameters cannot be passed by reference'); } } + if ($param->default && $i < $lastRequiredIndex) { + $this->fatalError( + $param, + $this->getFunctionDisplayName($functionDef) + . '(): optional parameter `$' . $phpName . '` cannot be declared before required parameter `$' + . $lastRequiredName . '`' + ); + } if ($this->method and $name === 'this_') { $this->fatalError($param, 'Cannot use `$this` as parameter of class method'); } @@ -325,17 +342,23 @@ class Preprocessor extends CompilerBase $argInfo->arrayInitPlan = $arrayInitPlan; $argInfo->defaultValue = $param->default; } - $defaultValueCount++; } elseif ($param->variadic) { // 变长参数可以视为空数组默认值 - $defaultValueCount++; $argInfo->default = '{}'; $argInfo->defaultValue = new Node\Expr\Array_(); } $functionDef->argInfoList[] = $argInfo; } $functionDef->params = implode(', ', $list); - $functionDef->argCountRequired -= $defaultValueCount; + $functionDef->argCountRequired = $lastRequiredIndex + 1; + } + + protected function getFunctionDisplayName(FunctionDef $functionDef): string + { + if ($this->class) { + return $this->class . '::' . $functionDef->name; + } + return $functionDef->getNamespacedName(); } protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): FunctionDef @@ -352,6 +375,16 @@ class Preprocessor extends CompilerBase if ($v->byRef) { $this->fatalError($v, 'The return type of the function `' . $v->name . '` cannot be a reference type'); } + if ($this->method and $v->returnType !== null) { + $methodName = $this->class . '::' . $this->method; + if (in_array($this->method, ['__construct', '__destruct'], true)) { + $this->fatalError($v, 'Method `' . $methodName . '()` cannot declare a return type'); + } + if ($this->method === '__clone' + and (!$v->returnType instanceof Node\Identifier or strtolower($v->returnType->name) !== 'void')) { + $this->fatalError($v, 'Method `' . $methodName . '()` return type must be void when declared'); + } + } $fnName = $this->parseIdentifier($v->name); $class = ''; @@ -435,6 +468,9 @@ class Preprocessor extends CompilerBase } else { $flags = Modifiers::PUBLIC; } + if (isset($this->symbolDeclInFile[$fullClassNameLower])) { + $this->fatalError($class, "Duplicate class `{$fullClassName}`"); + } $this->classDef = new ClassDef($this->class, $flags, $this->namespace); $this->addClass($fullClassName, $this->classDef); @@ -466,10 +502,6 @@ class Preprocessor extends CompilerBase } else { $this->classDef->trait = $class; } - if (isset($this->symbolDeclInFile[$fullClassNameLower])) { - $this->fatalError($class, "Duplicate class `{$fullClassName}`"); - } - $this->symbolDeclInFile[$fullClassNameLower] = $this->file; $code = ''; diff --git a/src/Php/Translator.php b/src/Php/Translator.php index 4e7d8059..2eef2ce0 100644 --- a/src/Php/Translator.php +++ b/src/Php/Translator.php @@ -2906,10 +2906,11 @@ CODE; private function genWrapperRequiredArgCountCheck(FunctionDef $functionDef, string $displayName): string { $required = $functionDef->argCountRequired; + $expected = $required === count($functionDef->argInfoList) ? 'exactly' : 'at least'; $message = 'php::concat({' . 'php::Str(' . $this->genCharPtr('Too few arguments to function ' . $displayName . '(), ', true) . '), ' . 'php::toString(php::getCallArgNum()), ' - . 'php::Str(' . $this->genCharPtr(' passed and exactly ' . $required . ' expected', true) . ')' + . 'php::Str(' . $this->genCharPtr(' passed and ' . $expected . ' ' . $required . ' expected', true) . ')' . '})'; $code = $this->getIndent() . 'if (UNEXPECTED(php::getCallArgNum() < ' . $required . ')) {' . PHP_EOL; diff --git a/tests/aot/closure/closure-param-defaults.phpt b/tests/aot/closure/closure-param-defaults.phpt index 51eff8ec..8a0c84a8 100644 --- a/tests/aot/closure/closure-param-defaults.phpt +++ b/tests/aot/closure/closure-param-defaults.phpt @@ -25,6 +25,16 @@ function main(): void var_dump(get_class($e)); var_dump($e->getMessage()); } + + $requiredWithDefault = function ($value, $default = 42) { + var_dump($value, $default); + }; + try { + $requiredWithDefault(); + } catch (\Throwable $e) { + var_dump(get_class($e)); + var_dump($e->getMessage()); + } } ?> --EXPECT-- @@ -39,3 +49,5 @@ array(3) { } string(18) "ArgumentCountError" string(74) "Too few arguments to function {closure}(), 0 passed and exactly 1 expected" +string(18) "ArgumentCountError" +string(75) "Too few arguments to function {closure}(), 0 passed and at least 1 expected" diff --git a/tests/aot/type_decl/nullable-required-param-check.phpt b/tests/aot/type_decl/nullable-required-param-check.phpt index e9bc531e..78174c6c 100644 --- a/tests/aot/type_decl/nullable-required-param-check.phpt +++ b/tests/aot/type_decl/nullable-required-param-check.phpt @@ -7,6 +7,11 @@ function expect_nullable_int(?int $x): void var_dump($x); } +function expect_nullable_with_default(?int $x, int $fallback = 1): void +{ + var_dump($x, $fallback); +} + function main(): void { $fn = 'expect_nullable_int'; @@ -16,8 +21,18 @@ function main(): void var_dump(get_class($e)); var_dump($e->getMessage()); } + + $fn = 'expect_nullable_with_default'; + try { + $fn(); + } catch (\Throwable $e) { + var_dump(get_class($e)); + var_dump($e->getMessage()); + } } ?> --EXPECT-- string(18) "ArgumentCountError" string(84) "Too few arguments to function expect_nullable_int(), 0 passed and exactly 1 expected" +string(18) "ArgumentCountError" +string(94) "Too few arguments to function expect_nullable_with_default(), 0 passed and at least 1 expected"