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
pull/17/head
韩天峰 2 months ago
parent 6059e07827
commit bd2a3da14c
  1. 25
      docs/COMPILER_CLI.md
  2. 1
      docs/INCOMPATIBLE_PHP_FEATURES.md
  3. 44
      phpunit/src/CompilerBaseApiTest.php
  4. 81
      src/CompilerBase.php
  5. 5
      src/Constants.php
  6. 26
      src/Translator.php
  7. 10
      src/gen_stub.php
  8. 15
      tests/aot/basic/echo-assignment-expression.phpt
  9. 50
      tests/aot/basic/pipe-operator.phpt
  10. 85
      tests/aot/dynamic_call/runtime-fallback-unified.phpt
  11. 77
      tests/aot/parent_call/parent-dynamic-method-call.phpt

@ -260,7 +260,30 @@ cat benchmark.prof
---
### 9. `--debug-line` - 启用调试行
### 9. `--php-version <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`

@ -46,7 +46,6 @@
## 表达式与控制流
- `echo` 不允许直接使用赋值表达式。
- `match` 的 arm condition 不能是 `match` 表达式。
- `foreach` by reference 的 value 只能是变量。
- `foreach` by reference 不支持 list destructuring。

@ -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('<?php $value = "hello" |> trim(...);');
}
public function testPhpLanguageVersionAcceptsPipeAt85AndRejectsInvalidValue(): void
{
$this->compiler->setPhpVersion('8.5');
$parser = $this->getPropertyValue('parser');
$this->assertNotEmpty($parser->parse('<?php $value = "hello" |> 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', "<?php\nfunction php84_source(): void {}\n");
file_put_contents($projectDir . '/php85.php', "<?php\nfunction php85_source(): void {}\n");
$files = $this->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(<<<YAML
sources:
- path: op-lt.php

@ -74,6 +74,7 @@ use PhpParser\PrettyPrinter;
class CompilerBase implements PropertyAccessContext
{
public const string DEFAULT_PHP_VERSION = '8.5';
use AstNodeType;
use FuncCallOptimizer;
use AnonClassGenerator;
@ -385,6 +386,7 @@ class CompilerBase implements PropertyAccessContext
protected bool $noProgress = false;
protected bool $forTest = false;
protected Parser $parser;
protected string $phpVersion = self::DEFAULT_PHP_VERSION;
protected PrettyPrinter $printer;
protected bool $isPhpZts = false; // PHP 是否为线程安全版本
@ -426,7 +428,7 @@ class CompilerBase implements PropertyAccessContext
$this->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)) {

@ -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',

@ -258,6 +258,7 @@ class Translator extends Preprocessor
$climate->tab()->out('--target-platform <triple> 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 <ver> 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(')

@ -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"]);

@ -0,0 +1,15 @@
--TEST--
echo accepts assignment expressions
--FILE--
<?php
function main(): void
{
echo $first = 'first', ':', $second = 2, "\n";
var_dump($first, $second);
}
?>
--EXPECT--
first:2
string(5) "first"
int(2)

@ -0,0 +1,50 @@
--TEST--
PHP 8.5 pipe operator
--FILE--
<?php
function mark(string $value): string
{
echo "left:$value\n";
return $value;
}
function suffix(string $value): string
{
return $value . '!';
}
class PipeFormatter
{
public static function wrap(string $value): string
{
return '[' . $value . ']';
}
public function suffix(string $value): string
{
return $value . '!';
}
}
function main(): void
{
$callable = suffix(...);
$result = mark(' hello ')
|> 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!"

@ -0,0 +1,85 @@
--TEST--
Dynamic class, function, callback and property chain use runtime fallback
--FILE--
<?php
class RuntimeFallbackLeaf
{
public string $value = 'leaf';
}
class RuntimeFallbackBox
{
public RuntimeFallbackLeaf $child;
public function __construct()
{
$this->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"

@ -0,0 +1,77 @@
--TEST--
parent dynamic method lookup uses the lexical parent class and current call scope
--FILE--
<?php
class ParentDynamicMethod
{
public function greet(string $name): string
{
return 'parent:' . $name;
}
protected function protectedGreet(): string
{
return 'protected parent';
}
}
class ChildDynamicMethod extends ParentDynamicMethod
{
public function greet(string $name): string
{
return 'child:' . $name;
}
public function callParent(string $method, mixed ...$args): mixed
{
return parent::$method(...$args);
}
}
class GrandchildDynamicMethod extends ChildDynamicMethod
{
public function greet(string $name): string
{
return 'grandchild:' . $name;
}
}
class ParentDynamicStaticMethod
{
public static function greet(string $name): string
{
return 'static parent:' . $name;
}
}
class ChildDynamicStaticMethod extends ParentDynamicStaticMethod
{
public static function greet(string $name): string
{
return 'static child:' . $name;
}
public static function callParent(string $method, mixed ...$args): mixed
{
return parent::$method(...$args);
}
}
function main(): void
{
$child = new ChildDynamicMethod;
var_dump($child->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"
Loading…
Cancel
Save