TypePHP 编译器
https://swoole.com/aot/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
453 lines
17 KiB
453 lines
17 KiB
<?php
|
|
|
|
namespace TypePhp\Tests;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use TypePhp\CompilerTest;
|
|
use TypePhp\Entity\ArgInfo;
|
|
use TypePhp\Exception\TestError;
|
|
use PhpParser\Node;
|
|
use PhpParser\Node\Stmt\Function_;
|
|
use PhpParser\Modifiers;
|
|
use PhpParser\ParserFactory;
|
|
|
|
class PreprocessorTest extends TestCase
|
|
{
|
|
private string $testDir;
|
|
private CompilerTest $compiler;
|
|
private \ReflectionClass $ref;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->testDir = sys_get_temp_dir() . '/preprocessor_test_' . uniqid();
|
|
mkdir($this->testDir, 0777, true);
|
|
$this->compiler = CompilerTest::create($this->testDir);
|
|
$this->ref = new \ReflectionClass($this->compiler);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
parent::tearDown();
|
|
if (is_dir($this->testDir)) {
|
|
$this->removeDirectory($this->testDir);
|
|
}
|
|
}
|
|
|
|
private function removeDirectory(string $dir): void
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
$files = array_diff(scandir($dir), ['.', '..']);
|
|
foreach ($files as $file) {
|
|
$path = $dir . DIRECTORY_SEPARATOR . $file;
|
|
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
|
|
}
|
|
rmdir($dir);
|
|
}
|
|
|
|
private function invokeMethod(string $method, ...$args): mixed
|
|
{
|
|
$m = $this->ref->getMethod($method);
|
|
$m->setAccessible(true);
|
|
return $m->invoke($this->compiler, ...$args);
|
|
}
|
|
|
|
private function setProperty(string $name, mixed $value): void
|
|
{
|
|
if (in_array($name, ['classes', 'interfaces', 'functions', 'classExtends'], true)) {
|
|
$symbols = $this->getProperty('symbols');
|
|
$method = match ($name) {
|
|
'classes' => 'replaceClasses',
|
|
'interfaces' => 'replaceInterfaces',
|
|
'functions' => 'replaceFunctions',
|
|
'classExtends' => 'replaceParents',
|
|
};
|
|
$symbols->{$method}($value);
|
|
return;
|
|
}
|
|
$prop = $this->ref->getProperty($name);
|
|
$prop->setAccessible(true);
|
|
$prop->setValue($this->compiler, $value);
|
|
}
|
|
|
|
private function getProperty(string $name): mixed
|
|
{
|
|
if (in_array($name, ['classes', 'interfaces', 'functions'], true)) {
|
|
$symbols = $this->getProperty('symbols');
|
|
return $symbols->{$name}();
|
|
}
|
|
$prop = $this->ref->getProperty($name);
|
|
$prop->setAccessible(true);
|
|
return $prop->getValue($this->compiler);
|
|
}
|
|
|
|
private function parseFunctionNode(string $code): Function_
|
|
{
|
|
$parser = (new ParserFactory())->createForHostVersion();
|
|
$stmts = $parser->parse($code);
|
|
$this->assertNotNull($stmts);
|
|
foreach ($stmts as $stmt) {
|
|
if ($stmt instanceof Function_) {
|
|
return $stmt;
|
|
}
|
|
}
|
|
$this->fail('No function node found');
|
|
}
|
|
|
|
// ========================================================================
|
|
// genArgumentDeclaration
|
|
// ========================================================================
|
|
|
|
public function testGenArgumentDeclarationSimple(): void
|
|
{
|
|
$arg = new ArgInfo();
|
|
$arg->name = 'count';
|
|
$arg->type = 'php::Int';
|
|
$result = $this->invokeMethod('genArgumentDeclaration', $arg);
|
|
$this->assertEquals('php::Int count', $result);
|
|
}
|
|
|
|
public function testGenArgumentDeclarationString(): void
|
|
{
|
|
$arg = new ArgInfo();
|
|
$arg->name = 'name';
|
|
$arg->type = 'php::Str';
|
|
$result = $this->invokeMethod('genArgumentDeclaration', $arg);
|
|
$this->assertEquals('php::Str name', $result);
|
|
}
|
|
|
|
public function testGenArgumentDeclarationObject(): void
|
|
{
|
|
$arg = new ArgInfo();
|
|
$arg->name = 'obj';
|
|
$arg->type = 'php::Object';
|
|
$result = $this->invokeMethod('genArgumentDeclaration', $arg);
|
|
$this->assertEquals('php::Object obj', $result);
|
|
}
|
|
|
|
public function testGenArgumentDeclarationVar(): void
|
|
{
|
|
$arg = new ArgInfo();
|
|
$arg->name = 'container';
|
|
$arg->type = 'php::Var';
|
|
$result = $this->invokeMethod('genArgumentDeclaration', $arg);
|
|
$this->assertEquals('php::Var container', $result);
|
|
}
|
|
|
|
// ========================================================================
|
|
// getCppFile
|
|
// ========================================================================
|
|
|
|
public function testGetCppFile(): void
|
|
{
|
|
$phpFile = '/home/user/project/src/app.php';
|
|
$result = $this->compiler->getCppFile($phpFile);
|
|
|
|
$this->assertStringEndsWith('.cc', $result);
|
|
$this->assertStringStartsWith($this->compiler->getBuildDir(), $result);
|
|
$this->assertStringContainsString('app', $result);
|
|
}
|
|
|
|
public function testGetCppFilePreservesRelativePath(): void
|
|
{
|
|
$phpFile = '/var/www/myapp/controllers/UserController.php';
|
|
$result = $this->compiler->getCppFile($phpFile);
|
|
|
|
$this->assertStringEndsWith('UserController.cc', $result);
|
|
$this->assertStringStartsWith($this->compiler->getBuildDir(), $result);
|
|
}
|
|
|
|
public function testGetCppFileDoesNotTrimSiblingDirectoryByCharacterPrefix(): void
|
|
{
|
|
$this->setProperty('buildDir', '/tmp/project/build');
|
|
|
|
$result = $this->compiler->getCppFile('/tmp/project2/src/app.php');
|
|
|
|
$this->assertSame('/tmp/project/build/project2/src/app.cc', $result);
|
|
}
|
|
|
|
public function testGetCppFileDotPhpReplaced(): void
|
|
{
|
|
$phpFile = '/tmp/test_file.php';
|
|
$result = $this->compiler->getCppFile($phpFile);
|
|
|
|
$this->assertStringEndsWith('test_file.cc', $result);
|
|
$this->assertStringNotContainsString('.php', basename($result));
|
|
}
|
|
|
|
// ========================================================================
|
|
// getObjectFile
|
|
// ========================================================================
|
|
|
|
public function testGetObjectFile(): void
|
|
{
|
|
$cppFile = $this->compiler->getBuildDir() . '/include/test.cc';
|
|
$result = $this->compiler->getObjectFile($cppFile);
|
|
|
|
$this->assertStringEndsWith('.o', $result);
|
|
$this->assertStringContainsString('test', $result);
|
|
}
|
|
|
|
public function testGetObjectFileDifferentObjectExtension(): void
|
|
{
|
|
// On Linux the object extension is .o
|
|
$path = '/some/path/file.cc';
|
|
$result = $this->compiler->getObjectFile($path);
|
|
$this->assertStringEndsWith('file.o', $result);
|
|
}
|
|
|
|
// ========================================================================
|
|
// getMethodName
|
|
// ========================================================================
|
|
|
|
public function testGetMethodName(): void
|
|
{
|
|
$method = new Node\Stmt\ClassMethod('handle');
|
|
$result = $this->invokeMethod('getMethodName', $method);
|
|
$this->assertEquals('handle', $result);
|
|
}
|
|
|
|
public function testGetMethodNameConstructor(): void
|
|
{
|
|
$method = new Node\Stmt\ClassMethod('__construct');
|
|
$result = $this->invokeMethod('getMethodName', $method);
|
|
$this->assertEquals('__construct', $result);
|
|
}
|
|
|
|
// ========================================================================
|
|
// getParentClass
|
|
// ========================================================================
|
|
|
|
public function testGetParentClassWithNamespace(): void
|
|
{
|
|
$this->setProperty('classExtends', [
|
|
'app\\controllers\\homecontroller' => 'app\\controllers\\basecontroller',
|
|
]);
|
|
$result = $this->compiler->getParentClass('App\\Controllers\\HomeController');
|
|
$this->assertEquals('app\\controllers\\basecontroller', $result);
|
|
}
|
|
|
|
public function testGetParentClassFullyQualified(): void
|
|
{
|
|
$this->setProperty('classExtends', [
|
|
'app\\entity\\user' => 'app\\entity\\base',
|
|
]);
|
|
$result = $this->compiler->getParentClass('\\App\\Entity\\User');
|
|
$this->assertEquals('app\\entity\\base', $result);
|
|
}
|
|
|
|
// ========================================================================
|
|
// sortFiles
|
|
// ========================================================================
|
|
|
|
public function testSortFilesPreservesOrderForUnrelatedFiles(): void
|
|
{
|
|
$files = ['/a/file1.php', '/a/file2.php', '/a/file3.php'];
|
|
$this->compiler->sortFiles($files);
|
|
// All original files must still be present
|
|
$this->assertContains('/a/file1.php', $files);
|
|
$this->assertContains('/a/file2.php', $files);
|
|
$this->assertContains('/a/file3.php', $files);
|
|
// Original files are preserved (sortFiles may append, not remove)
|
|
$this->assertGreaterThanOrEqual(3, count($files));
|
|
}
|
|
|
|
public function testSortFilesEmpty(): void
|
|
{
|
|
$files = [];
|
|
$this->compiler->sortFiles($files);
|
|
// Empty array stays empty or nearly empty
|
|
$this->assertIsArray($files);
|
|
}
|
|
|
|
public function testPrepareFileParsesInterfaceMembersAndTypeChecks(): void
|
|
{
|
|
$file = __DIR__ . '/../code/preprocessor/interface_members.php';
|
|
|
|
$this->compiler->prepareFile($file);
|
|
|
|
$interfaces = $this->getProperty('interfaces');
|
|
$this->assertArrayHasKey('demo', $interfaces);
|
|
|
|
$iface = $interfaces['demo'];
|
|
$this->assertSame(['ParentA', 'ParentB'], $iface->extendsList);
|
|
$this->assertSame('ParentA', $iface->extends);
|
|
$this->assertArrayHasKey('VERSION', $iface->constants);
|
|
$this->assertTrue($iface->hasMethod('run'));
|
|
|
|
$functionDef = $iface->methods['run']->functionDef;
|
|
$this->assertTrue($functionDef->method);
|
|
$this->assertSame('php::Int', $functionDef->argInfoList[0]->type);
|
|
$this->assertNull($functionDef->argInfoList[0]->typeCheck);
|
|
$this->assertSame('php::Var', $functionDef->argInfoList[1]->type);
|
|
$this->assertNotEmpty($functionDef->argInfoList[1]->typeCheck);
|
|
$this->assertSame('php::Var', $functionDef->returnType);
|
|
$this->assertNotEmpty($functionDef->returnTypeCheck);
|
|
}
|
|
|
|
public function testPrepareFileParsesInterfaceArrayConstantInitExpr(): void
|
|
{
|
|
$file = __DIR__ . '/../code/interface_array_constant.php';
|
|
|
|
$this->compiler->prepareFile($file);
|
|
|
|
$interfaces = $this->getProperty('interfaces');
|
|
$this->assertArrayHasKey('interfacearrayconstant', $interfaces);
|
|
$constant = $interfaces['interfacearrayconstant']->constants['ITEMS'];
|
|
|
|
$this->assertSame('php::Array', $constant->type);
|
|
$this->assertStringContainsString('php::Array', $constant->value);
|
|
}
|
|
|
|
public function testPrepareFileRejectsNamespacedDuplicateClassAndInterface(): void
|
|
{
|
|
$this->expectException(TestError::class);
|
|
$this->expectExceptionMessage('Duplicate class `App\\Demo`');
|
|
|
|
$file = __DIR__ . '/../code/preprocessor/duplicate_namespaced_class_interface.php';
|
|
$this->compiler->prepareFile($file);
|
|
}
|
|
|
|
public function testPrepareFileParsesNamespacedConstants(): void
|
|
{
|
|
$file = __DIR__ . '/../code/preprocessor/namespaced_const.php';
|
|
|
|
$this->compiler->prepareFile($file);
|
|
|
|
$constants = $this->getProperty('constants');
|
|
$this->assertArrayHasKey('_const_var_App__VERSION', $constants);
|
|
$this->assertSame('App\\VERSION', $constants['_const_var_App__VERSION']->name);
|
|
}
|
|
|
|
public function testSortFilesUsesImplementsAndTraitDependencies(): void
|
|
{
|
|
$classFile = realpath(__DIR__ . '/../code/preprocessor/deps_class_implements.php');
|
|
$interfaceFile = realpath(__DIR__ . '/../code/preprocessor/deps_interface.php');
|
|
$traitUserFile = realpath(__DIR__ . '/../code/preprocessor/deps_class_uses_trait.php');
|
|
$traitFile = realpath(__DIR__ . '/../code/preprocessor/deps_trait.php');
|
|
|
|
$this->compiler->prepareFile($classFile);
|
|
$this->compiler->prepareFile($interfaceFile);
|
|
$this->compiler->prepareFile($traitUserFile);
|
|
$this->compiler->prepareFile($traitFile);
|
|
|
|
$files = [$classFile, $interfaceFile, $traitUserFile, $traitFile];
|
|
$this->compiler->sortFiles($files);
|
|
|
|
$this->assertLessThan(array_search($classFile, $files, true), array_search($interfaceFile, $files, true));
|
|
$this->assertLessThan(array_search($traitUserFile, $files, true), array_search($traitFile, $files, true));
|
|
}
|
|
|
|
public function testPrepareFileParsesTraitAliasModifierWithoutNewName(): void
|
|
{
|
|
$file = __DIR__ . '/../code/preprocessor/trait_alias_modifier.php';
|
|
|
|
$this->compiler->prepareFile($file);
|
|
|
|
$classes = $this->getProperty('classes');
|
|
$this->assertArrayHasKey('aliasmodifieruser', $classes);
|
|
$aliases = $classes['aliasmodifieruser']->traitAliases;
|
|
$this->assertArrayHasKey('aliasmodifiertrait::hello', $aliases);
|
|
$this->assertSame('hello', $aliases['aliasmodifiertrait::hello'][0]['newName']);
|
|
$this->assertSame(Modifiers::PRIVATE, $aliases['aliasmodifiertrait::hello'][0]['newModifier']);
|
|
}
|
|
|
|
public function testPrepareFileInfersEachClassConstantTypeIndependently(): void
|
|
{
|
|
$file = __DIR__ . '/../code/preprocessor/class_constants.php';
|
|
|
|
$this->compiler->prepareFile($file);
|
|
|
|
$classes = $this->getProperty('classes');
|
|
$this->assertArrayHasKey('preprocessorclassconstants', $classes);
|
|
$constants = $classes['preprocessorclassconstants']->constants;
|
|
$this->assertSame('php::Str', $constants['TEXT']->type);
|
|
$this->assertSame('php::Array', $constants['ITEMS']->type);
|
|
}
|
|
|
|
public function testPrepareFileRejectsDuplicateClassConstants(): void
|
|
{
|
|
$this->expectException(TestError::class);
|
|
$this->expectExceptionMessage('Duplicate constant `VALUE`');
|
|
|
|
$file = __DIR__ . '/../code/preprocessor/duplicate_class_constant.php';
|
|
$this->compiler->prepareFile($file);
|
|
}
|
|
|
|
public function testPrepareFileParsesAbstractMethodSignatures(): void
|
|
{
|
|
$file = __DIR__ . '/../code/preprocessor/abstract_method_signature.php';
|
|
|
|
$this->compiler->prepareFile($file);
|
|
|
|
$classes = $this->getProperty('classes');
|
|
$this->assertArrayHasKey('preprocessorabstractsignature', $classes);
|
|
$methodDef = $classes['preprocessorabstractsignature']->abstractMethodDefs['load'];
|
|
$functionDef = $methodDef->functionDef;
|
|
$this->assertTrue($functionDef->method);
|
|
$this->assertSame('php::Int', $functionDef->argInfoList[0]->type);
|
|
$this->assertSame('php::Var', $functionDef->argInfoList[1]->type);
|
|
$this->assertNotEmpty($functionDef->argInfoList[1]->typeCheck);
|
|
$this->assertSame('php::Object', $functionDef->returnType);
|
|
$this->assertSame('PreprocessorAbstractSignature', $functionDef->returnClass);
|
|
}
|
|
|
|
public function testPrepareFileRejectsDuplicateAbstractMethods(): void
|
|
{
|
|
$this->expectException(TestError::class);
|
|
$this->expectExceptionMessage('Duplicate method `load`');
|
|
|
|
$file = __DIR__ . '/../code/preprocessor/duplicate_abstract_method.php';
|
|
$this->compiler->prepareFile($file);
|
|
}
|
|
|
|
public function testPrepareFileRejectsAttributeArrayArguments(): void
|
|
{
|
|
$this->expectException(TestError::class);
|
|
$this->expectExceptionMessage('Array arguments to attributes are not supported');
|
|
|
|
$file = __DIR__ . '/../code/preprocessor/attribute_array_argument.php';
|
|
$this->compiler->prepareFile($file);
|
|
}
|
|
|
|
public function testPrepareFileRejectsAttributeNewExpressionArguments(): void
|
|
{
|
|
$this->expectException(TestError::class);
|
|
$this->expectExceptionMessage('New expressions in attribute arguments are not supported');
|
|
|
|
$file = __DIR__ . '/../code/preprocessor/attribute_new_expression_argument.php';
|
|
$this->compiler->prepareFile($file);
|
|
}
|
|
|
|
public function testIntersectionParamDeclFallsBackToVarWithRuntimeCheck(): void
|
|
{
|
|
$fn = $this->parseFunctionNode('<?php interface A {} interface B {} function demo(A&B $value): void {}');
|
|
$functionDef = $this->invokeMethod('parseFunctionDecl', $fn);
|
|
|
|
$this->assertSame('php::Var', $functionDef->argInfoList[0]->type);
|
|
$this->assertNotEmpty($functionDef->argInfoList[0]->typeCheck);
|
|
$this->assertSame('A&B', $functionDef->argInfoList[0]->typeStr);
|
|
}
|
|
|
|
public function testIntersectionReturnDeclFallsBackToVarWithRuntimeCheck(): void
|
|
{
|
|
$fn = $this->parseFunctionNode('<?php interface A {} interface B {} function demo(): A&B { throw new \Exception(); }');
|
|
$functionDef = $this->invokeMethod('parseFunctionDecl', $fn);
|
|
|
|
$this->assertSame('php::Var', $functionDef->returnType);
|
|
$this->assertNotEmpty($functionDef->returnTypeCheck);
|
|
$this->assertSame('A&B', $functionDef->returnTypeStr);
|
|
}
|
|
|
|
public function testNullableReturnDeclFallsBackToVarWithRuntimeCheck(): void
|
|
{
|
|
$fn = $this->parseFunctionNode('<?php function demo(): ?int { return null; }');
|
|
$functionDef = $this->invokeMethod('parseFunctionDecl', $fn);
|
|
|
|
$this->assertSame('php::Var', $functionDef->returnType);
|
|
$this->assertNotEmpty($functionDef->returnTypeCheck);
|
|
$this->assertSame('?int', $functionDef->returnTypeStr);
|
|
}
|
|
}
|
|
|