From bd2a3da14c7171452d9e41d6f511672931e60d63 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sat, 11 Jul 2026 10:40:52 +0800 Subject: [PATCH] feat(compiler): add PHP version control and pipe operator support - Introduce --php-version CLI option to specify accepted PHP syntax version - Add support for PHP 8.5 pipe operator with left-to-right evaluation - Implement PHP version validation in project configuration parsing - Update parser factory usage to respect configured PHP version - Add tests for pipe operator functionality and PHP version controls - Modify stub generation to use configured PHP version - Update documentation with new --php-version option details --- docs/COMPILER_CLI.md | 25 +++++- docs/INCOMPATIBLE_PHP_FEATURES.md | 1 - phpunit/src/CompilerBaseApiTest.php | 44 +++++++++- src/CompilerBase.php | 81 +++++++++++++++++- src/Constants.php | 5 ++ src/Translator.php | 26 ++++-- src/gen_stub.php | 10 ++- .../aot/basic/echo-assignment-expression.phpt | 15 ++++ tests/aot/basic/pipe-operator.phpt | 50 +++++++++++ .../runtime-fallback-unified.phpt | 85 +++++++++++++++++++ .../parent-dynamic-method-call.phpt | 77 +++++++++++++++++ 11 files changed, 406 insertions(+), 13 deletions(-) create mode 100644 tests/aot/basic/echo-assignment-expression.phpt create mode 100644 tests/aot/basic/pipe-operator.phpt create mode 100644 tests/aot/dynamic_call/runtime-fallback-unified.phpt create mode 100644 tests/aot/parent_call/parent-dynamic-method-call.phpt diff --git a/docs/COMPILER_CLI.md b/docs/COMPILER_CLI.md index b067eebd..cfef0cb5 100644 --- a/docs/COMPILER_CLI.md +++ b/docs/COMPILER_CLI.md @@ -260,7 +260,30 @@ cat benchmark.prof --- -### 9. `--debug-line` - 启用调试行 +### 9. `--php-version ` - 限制 PHP 语言版本 + +**默认值**: `8.5` + +指定编译器接受的 PHP 语法版本。可选值为 `8.2`、`8.3`、`8.4` 和 `8.5`。选择较低版本时,较新版本的语法会在编译阶段直接报错。 + +该选项也会影响 `project.yml` 的 `PHP_VERSION` 与 `PHP_VERSION_ID` 源文件条件。命令行参数优先于 YAML 配置。 + +```bash +# 按 PHP 8.4 语法编译;PHP 8.5 的 Pipe Operator 将被拒绝 +./bin/compiler.php app.php --php-version 8.4 +``` + +也可以在 `project.yml` 中设置: + +```yaml +php-version: '8.4' +sources: + - main.php +``` + +--- + +### 10. `--debug-line` - 启用调试行 **默认值**: `0` diff --git a/docs/INCOMPATIBLE_PHP_FEATURES.md b/docs/INCOMPATIBLE_PHP_FEATURES.md index 0021246c..6dc9e363 100644 --- a/docs/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/INCOMPATIBLE_PHP_FEATURES.md @@ -46,7 +46,6 @@ ## 表达式与控制流 -- `echo` 不允许直接使用赋值表达式。 - `match` 的 arm condition 不能是 `match` 表达式。 - `foreach` by reference 的 value 只能是变量。 - `foreach` by reference 不支持 list destructuring。 diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index c25fe4ba..bf7057d8 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -5,6 +5,7 @@ namespace TypePhp\Tests; use PHPUnit\Framework\TestCase; use TypePhp\CompilerTest; use TypePhp\CompilerBase; +use TypePhp\Exception\TestError; use TypePhp\Platform\Windows; class CompilerBaseApiTest extends TestCase @@ -115,6 +116,27 @@ class CompilerBaseApiTest extends TestCase $this->assertSame(CompilerBase::BUILD_MODE_BIN, $this->compiler->getBuildMode()); } + public function testPhpLanguageVersionControlsParser(): void + { + $this->assertSame('8.5.0', $this->compiler->getPhpVersion()); + + $this->compiler->setPhpVersion('8.4'); + $this->assertSame('8.4.0', $this->compiler->getPhpVersion()); + $parser = $this->getPropertyValue('parser'); + $this->expectException(\PhpParser\Error::class); + $parser->parse(' trim(...);'); + } + + public function testPhpLanguageVersionAcceptsPipeAt85AndRejectsInvalidValue(): void + { + $this->compiler->setPhpVersion('8.5'); + $parser = $this->getPropertyValue('parser'); + $this->assertNotEmpty($parser->parse(' trim(...);')); + + $this->expectException(TestError::class); + $this->compiler->setPhpVersion('8.1'); + } + public function testMiscObjectCacheIsInvalidatedWhenCompileOptionsChange(): void { $source = $this->testDir . '/typephp_runtime.cc'; @@ -596,9 +618,29 @@ YAML); ); } + public function testProjectPhpVersionControlsConditionalSources(): void + { + $projectFile = $this->createProjectFile(<<<'YAML' +php-version: '8.4' +sources: + - path: php84.php + if: PHP_VERSION_ID == 80400 + - path: php85.php + if: PHP_VERSION >= '8.5' +YAML); + $projectDir = dirname($projectFile); + file_put_contents($projectDir . '/php84.php', "invokeMethod('parseProjectYaml', $projectFile); + + $this->assertSame('8.4.0', $this->compiler->getPhpVersion()); + $this->assertSame([realpath($projectDir . '/php84.php')], $files); + } + public function testParseProjectYamlSupportsAllVersionCompareOperators(): void { - $current = PHP_VERSION; + $current = $this->compiler->getPhpVersion(); $projectFile = $this->createProjectFile(<<error('PHP 8.6.0 or later is not supported'); } $this->rootPath = $rootPath; - $this->parser = (new ParserFactory())->createForVersion(PhpVersion::fromString(PHP_VERSION)); + $this->setPhpVersion(self::DEFAULT_PHP_VERSION); $this->printer = new PrettyPrinter\Standard(); $this->setBuildDir($rootPath . '/build'); $climate = new CLImate(); @@ -438,6 +440,24 @@ class CompilerBase implements PropertyAccessContext $this->mode = $mode; } + /** Set the PHP language version accepted by the parser. */ + public function setPhpVersion(string $version): void + { + if (!preg_match('/^8\.(2|3|4|5)(?:\.0)?$/', $version, $matches)) { + $this->error('Unsupported PHP language version: `' . $version . '`. Supported versions: 8.2, 8.3, 8.4, 8.5'); + } + + $this->phpVersion = '8.' . $matches[1] . '.0'; + // php-parser's emulative lexer permits the compiler runtime to be + // older than the selected PHP language version. + $this->parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($this->phpVersion)); + } + + public function getPhpVersion(): string + { + return $this->phpVersion; + } + public function setIndent(string $indent): void { $this->indentStr = $indent; @@ -704,6 +724,8 @@ class CompilerBase implements PropertyAccessContext return $this->parseBinaryOpBitwiseOr($expr); case 'Expr_BinaryOp_BitwiseXor': return $this->parseBinaryOpBitwiseXor($expr); + case 'Expr_BinaryOp_Pipe': + return $this->parsePipeOperator($expr); case 'Expr_BitwiseNot': return $this->parseBitwiseNot($expr); case 'Expr_BinaryOp_Mod': @@ -3551,6 +3573,63 @@ class CompilerBase implements PropertyAccessContext } } + /** + * PHP 8.5 pipe operator: $value |> $callable. + * + * The left operand is evaluated first and passed as the single value + * argument to the callable on the right. Materialising the left operand + * also avoids relying on C++ argument-evaluation order. + */ + protected function parsePipeOperator(Expr\BinaryOp\Pipe $expr): string + { + $this->assertExprCanBeUsedAsValue($expr->left, 'pipe left operand'); + $this->assertExprCanBeUsedAsValue($expr->right, 'pipe callable'); + + [$leftExpr, $beforeStmts, $afterStmts] = $this->parseExprWithCapturedStmts($expr->left); + $this->appendCapturedStmtLinesToContext($beforeStmts); + $value = $this->addTmpVar(self::TYPE_VAR); + $this->context->beforeStmtLines[] = $value . ' = ' . $leftExpr . ';'; + $this->appendCapturedStmtLinesToContext($afterStmts); + + $directCall = $this->parsePipeFirstClassCallable($expr->right, $value); + if ($directCall !== null) { + return $directCall; + } + + $callable = $this->parseExprAsValue($expr->right); + return 'php::call(' . $callable . ', {' . $value . '})'; + } + + /** + * Lower a first-class callable used as a pipe target to its direct call. + * + * `trim(...)`, `ClassName::method(...)`, and `$object->method(...)` do + * not need a Closure when the pipe immediately invokes them. Reusing the + * ordinary call parsers preserves native-call optimization, argument + * validation, visibility checks, and the left-to-right evaluation order. + */ + protected function parsePipeFirstClassCallable(NodeAbstract $callable, string $value): ?string + { + if (!$callable instanceof CallLike || !$callable->isFirstClassCallable()) { + return null; + } + + $directCall = clone $callable; + $directCall->args = [new Node\Arg(new Variable($value))]; + + if ($directCall instanceof Expr\FuncCall) { + return $this->parseFuncCall($directCall); + } + if ($directCall instanceof Expr\StaticCall) { + return $this->parseStaticCall($directCall); + } + if ($directCall instanceof Expr\MethodCall) { + return $this->parseMethodCall($directCall); + } + + return null; + } + protected function parseFuncCall(Expr\FuncCall $expr): string { if ($this->isVarExpr($expr->name)) { diff --git a/src/Constants.php b/src/Constants.php index a54fa578..71435118 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -155,6 +155,11 @@ class Constants 'required' => false, 'noValue' => true, ], + 'php-version' => [ + 'longPrefix' => 'php-version', + 'description' => 'PHP language version to accept (8.2, 8.3, 8.4, or 8.5; default: 8.5)', + 'required' => false, + ], 'force' => [ 'prefix' => 'f', 'longPrefix' => 'force', diff --git a/src/Translator.php b/src/Translator.php index 50a7e5ad..e6e38c45 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -258,6 +258,7 @@ class Translator extends Preprocessor $climate->tab()->out('--target-platform Cross-compilation target triple (e.g. aarch64-linux-gnu)'); $climate->tab()->out('--lto Enable Link Time Optimization (-flto)'); $climate->tab()->out('--no-literal-strings Disable literal strings optimization'); + $climate->tab()->out('--php-version PHP language version to accept (8.2-8.5, default: 8.5)'); $climate->tab()->out('--no-progress Disable progress bar, output per-file compilation progress line by line'); $climate->tab()->out('--no-console Hide console window (Windows only, GUI application)'); $climate->tab()->out('--no-color Disable ANSI color output'); @@ -277,6 +278,8 @@ class Translator extends Preprocessor */ protected function applyCommandLineArguments(): void { + $this->applyPhpVersionCommandLineArgument(); + // 优化级别 if ($this->climate->arguments->defined('optimize')) { $this->optimizeLevel = $this->climate->arguments->get('optimize'); @@ -393,6 +396,14 @@ class Translator extends Preprocessor } } + /** Apply this option early because YAML source conditions depend on it. */ + protected function applyPhpVersionCommandLineArgument(): void + { + if ($this->climate->arguments->defined('php-version')) { + $this->setPhpVersion((string) $this->climate->arguments->get('php-version')); + } + } + /** * 从原始 $argv 中解析可重复参数,支持 -X val 和 --long val 两种形式。 * CLImate 的 multiple 选项只能保留最后一个值,因此需要手动解析。 @@ -655,6 +666,7 @@ class Translator extends Preprocessor public function getFiles(string $path): array { + $this->applyPhpVersionCommandLineArgument(); $realpath = realpath($path); if ($realpath === false) { $this->error("path not exists: {$path}"); @@ -2343,6 +2355,10 @@ CODE; $cfg = Yaml::parseFile($path); $projectDir = dirname($path); + if (array_key_exists('php-version', $cfg) && !$this->climate->arguments->defined('php-version')) { + $this->setPhpVersion((string) $cfg['php-version']); + } + if (!empty($cfg['sources'])) { $sources = $cfg['sources']; if (!is_array($sources)) { @@ -2639,7 +2655,7 @@ CODE; $expr = preg_replace_callback( '/\bPHP_VERSION_ID\b\s*' . $operator . '\s*([0-9]+)/i', function (array $matches): string { - return version_compare(PHP_VERSION, $this->phpVersionIdToString((int) $matches[2]), $this->normalizeProjectYamlVersionOperator($matches[1])) ? '1' : '0'; + return version_compare($this->phpVersion, $this->phpVersionIdToString((int) $matches[2]), $this->normalizeProjectYamlVersionOperator($matches[1])) ? '1' : '0'; }, $condition ); @@ -2650,7 +2666,7 @@ CODE; $expr = preg_replace_callback( '/([0-9]+)\s*' . $operator . '\s*\bPHP_VERSION_ID\b/i', function (array $matches): string { - return version_compare($this->phpVersionIdToString((int) $matches[1]), PHP_VERSION, $this->normalizeProjectYamlVersionOperator($matches[2])) ? '1' : '0'; + return version_compare($this->phpVersionIdToString((int) $matches[1]), $this->phpVersion, $this->normalizeProjectYamlVersionOperator($matches[2])) ? '1' : '0'; }, $expr ); @@ -2663,7 +2679,7 @@ CODE; function (array $matches): string { $version = stripcslashes(($matches[3] ?? '') !== '' ? $matches[3] : $matches[4]); $this->assertProjectYamlVersionLiteral($version); - return version_compare(PHP_VERSION, $version, $this->normalizeProjectYamlVersionOperator($matches[1])) ? '1' : '0'; + return version_compare($this->phpVersion, $version, $this->normalizeProjectYamlVersionOperator($matches[1])) ? '1' : '0'; }, $expr ); @@ -2676,7 +2692,7 @@ CODE; function (array $matches): string { $version = stripcslashes($matches[2] !== '' ? $matches[2] : $matches[3]); $this->assertProjectYamlVersionLiteral($version); - return version_compare($version, PHP_VERSION, $this->normalizeProjectYamlVersionOperator($matches[4])) ? '1' : '0'; + return version_compare($version, $this->phpVersion, $this->normalizeProjectYamlVersionOperator($matches[4])) ? '1' : '0'; }, $expr ); @@ -3007,7 +3023,7 @@ CODE; $headerFile = $this->getArgInfoHeaderFile($file, true); $this->climate->info('generate arginfo file: ' . $this->getRelativePath($file)); - generateStubFile($file, $this->getIncludeDir() . '/' . $headerFile, true); + generateStubFile($file, $this->getIncludeDir() . '/' . $headerFile, true, $this->getPhpVersion()); $headerCode = file_get_contents($this->getBuildDir() . '/include/' . $headerFile); $needsAttributeSymbols = str_contains($headerCode, 'zend_add_function_attribute(') diff --git a/src/gen_stub.php b/src/gen_stub.php index 0c0a1dcd..4e5b5df9 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -104,7 +104,7 @@ function processStubFile(string $stubFile, Context $context, bool $includeOnly = if (!$fileInfo = $context->parsedFiles[$stubFile] ?? null) { initPhpParser(); $stubContent = $stubCode ?? file_get_contents($stubFile); - $fileInfo = FileInfo::parseStubFile($stubContent); + $fileInfo = FileInfo::parseStubFile($stubContent, $context->phpVersion); $context->parsedFiles[$stubFile] = $fileInfo; foreach ($fileInfo->dependencies as $dependency) { @@ -182,6 +182,7 @@ class Context { /** @var FileInfo[] */ public array $parsedFiles = []; public string $objectFile = ''; + public string $phpVersion = '8.5'; } class ArrayType extends SimpleType { @@ -4457,8 +4458,8 @@ class FileInfo { return $legacyFileInfo; } - public static function parseStubFile(string $code): FileInfo { - $parser = new PhpParser\Parser\Php7(new PhpParser\Lexer\Emulative()); + public static function parseStubFile(string $code, string $phpVersion = '8.5'): FileInfo { + $parser = (new PhpParser\ParserFactory())->createForVersion(PhpParser\PhpVersion::fromString($phpVersion)); $nodeTraverser = new PhpParser\NodeTraverser; $nodeTraverser->addVisitor(new PhpParser\NodeVisitor\NameResolver); $prettyPrinter = new class extends Standard { @@ -6353,7 +6354,7 @@ function getTranslator(): Translator /** * @throws Exception */ -function generateStubFile(string $stubFile, string $objectFile, bool $forceRegeneration): void +function generateStubFile(string $stubFile, string $objectFile, bool $forceRegeneration, string $phpVersion = '8.5'): void { $opt_index = 0; $options = getopt( @@ -6367,6 +6368,7 @@ function generateStubFile(string $stubFile, string $objectFile, bool $forceRegen ); $context = new Context; + $context->phpVersion = $phpVersion; $printParameterStats = isset($options["parameter-stats"]); $verify = isset($options["verify"]); $verifyManual = isset($options["verify-manual"]); diff --git a/tests/aot/basic/echo-assignment-expression.phpt b/tests/aot/basic/echo-assignment-expression.phpt new file mode 100644 index 00000000..fb5a31c1 --- /dev/null +++ b/tests/aot/basic/echo-assignment-expression.phpt @@ -0,0 +1,15 @@ +--TEST-- +echo accepts assignment expressions +--FILE-- + +--EXPECT-- +first:2 +string(5) "first" +int(2) diff --git a/tests/aot/basic/pipe-operator.phpt b/tests/aot/basic/pipe-operator.phpt new file mode 100644 index 00000000..9766915b --- /dev/null +++ b/tests/aot/basic/pipe-operator.phpt @@ -0,0 +1,50 @@ +--TEST-- +PHP 8.5 pipe operator +--FILE-- + trim(...) + |> PipeFormatter::wrap(...) + |> $callable + |> (fn(string $value): string => strtoupper($value)); + + $formatter = new PipeFormatter(); + $methodResult = mark('method') |> $formatter->suffix(...); + + var_dump($result); + var_dump($methodResult); +} +?> +--EXPECT-- +left: hello +left:method +string(8) "[HELLO]!" +string(7) "method!" diff --git a/tests/aot/dynamic_call/runtime-fallback-unified.phpt b/tests/aot/dynamic_call/runtime-fallback-unified.phpt new file mode 100644 index 00000000..f66e43f1 --- /dev/null +++ b/tests/aot/dynamic_call/runtime-fallback-unified.phpt @@ -0,0 +1,85 @@ +--TEST-- +Dynamic class, function, callback and property chain use runtime fallback +--FILE-- +child = new RuntimeFallbackLeaf(); + } + + public function format(string $value): string + { + return 'method:' . $value; + } +} + +class RuntimeFallbackStatic +{ + public static string $value = 'before'; + + public static function format(string $value): string + { + return 'static:' . $value; + } +} + +function runtime_fallback_function(string $value): string +{ + return 'function:' . $value; +} + +function class_name(): string +{ + return RuntimeFallbackBox::class; +} + +function function_name(): string +{ + return 'runtime_fallback_function'; +} + +function main(): void +{ + $class = class_name(); + $box = new $class(); + $property = 'child'; + $nested = 'value'; + var_dump($box->$property->$nested); + $box->$property->$nested = 'changed'; + var_dump($box->$property->$nested); + + $function = function_name(); + var_dump($function('ok')); + + $method = 'format'; + $callback = [$box, $method]; + var_dump($callback('ok')); + + $staticClass = any(RuntimeFallbackStatic::class); + $staticProperty = any('value'); + var_dump($staticClass::$$staticProperty); + $staticClass::$$staticProperty = 'after'; + var_dump($staticClass::$$staticProperty); + + $staticMethod = any('format'); + var_dump($staticClass::$staticMethod('ok')); +} +?> +--EXPECT-- +string(4) "leaf" +string(7) "changed" +string(11) "function:ok" +string(9) "method:ok" +string(6) "before" +string(5) "after" +string(9) "static:ok" diff --git a/tests/aot/parent_call/parent-dynamic-method-call.phpt b/tests/aot/parent_call/parent-dynamic-method-call.phpt new file mode 100644 index 00000000..a5c88bcb --- /dev/null +++ b/tests/aot/parent_call/parent-dynamic-method-call.phpt @@ -0,0 +1,77 @@ +--TEST-- +parent dynamic method lookup uses the lexical parent class and current call scope +--FILE-- +callParent('greet', 'Ada')); + var_dump($child->callParent('protectedGreet')); + + $grandchild = new GrandchildDynamicMethod; + var_dump($grandchild->callParent('greet', 'Lin')); + + var_dump(ChildDynamicStaticMethod::callParent('greet', 'Sam')); +} +?> +--EXPECT-- +string(10) "parent:Ada" +string(16) "protected parent" +string(10) "parent:Lin" +string(17) "static parent:Sam"