From 75bf9f41139fe5bc178296b683881c11cd0d43a1 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Thu, 9 Jul 2026 12:47:02 +0800 Subject: [PATCH] feat(compiler): add compiler command validation and path resolution - Implemented isCommandExecutable method to check if compiler commands are executable - Added getCommandProgram method to extract program name from quoted/argumented commands - Created isPathLikeCommand helper for path-based command detection - Added compiler toolchain validation in Translator prepare phase - Implemented proper PATH environment lookup with extension handling on Windows - Added comprehensive unit tests for command parsing and executability checks - Enhanced error reporting when compiler/linker executables are not found - Moved file collection before compiler validation in translation process --- phpunit/src/FactoryTest.php | 66 +++++++++++++++++++++++++++ src/Php/Backend/CompilerFactory.php | 70 ++++++++++++++++++++++++++++- src/Php/Translator.php | 28 +++++++++++- 3 files changed, 161 insertions(+), 3 deletions(-) diff --git a/phpunit/src/FactoryTest.php b/phpunit/src/FactoryTest.php index 25c42817..fc81dc54 100644 --- a/phpunit/src/FactoryTest.php +++ b/phpunit/src/FactoryTest.php @@ -11,6 +11,48 @@ use PhpAot\Php\Platform\Macos; class FactoryTest extends TestCase { + private string|false $originalPath; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + $this->originalPath = getenv('PATH'); + $this->tmpDir = sys_get_temp_dir() . '/compiler_factory_test_' . uniqid(); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + if ($this->originalPath === false) { + putenv('PATH'); + } else { + putenv('PATH=' . $this->originalPath); + } + $this->removeDirectory($this->tmpDir); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (array_diff(scandir($dir), ['.', '..']) as $file) { + $path = $dir . DIRECTORY_SEPARATOR . $file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + private function createFakeExecutable(string $name): string + { + $path = $this->tmpDir . DIRECTORY_SEPARATOR . $name; + file_put_contents($path, "#!/bin/sh\nexit 0\n"); + chmod($path, 0755); + return $path; + } + /** * 测试 PlatformFactory 自动检测 */ @@ -172,4 +214,28 @@ class FactoryTest extends TestCase $this->assertSame($platform, $retrievedPlatform); } + + public function testCompilerCommandProgramParsesArgumentsAndQuotes(): void + { + $this->assertSame('clang++', CompilerFactory::getCommandProgram('clang++ -stdlib=libc++')); + $this->assertSame('/opt/llvm/bin/clang++', CompilerFactory::getCommandProgram('"/opt/llvm/bin/clang++" -O2')); + $this->assertSame('C:\\LLVM\\bin\\clang++.exe', CompilerFactory::getCommandProgram('"C:\\LLVM\\bin\\clang++.exe" -O2')); + $this->assertSame('', CompilerFactory::getCommandProgram(' ')); + } + + public function testCompilerCommandExecutableUsesPathAndIgnoresArguments(): void + { + $this->createFakeExecutable('fake-g++'); + putenv('PATH=' . $this->tmpDir); + + $this->assertTrue(CompilerFactory::isCommandExecutable('fake-g++ -std=c++20')); + $this->assertFalse(CompilerFactory::isCommandExecutable('missing-g++ -std=c++20')); + } + + public function testCompilerCommandExecutableAcceptsQuotedAbsolutePath(): void + { + $compiler = $this->createFakeExecutable('quoted-clang++'); + + $this->assertTrue(CompilerFactory::isCommandExecutable('"' . $compiler . '" -O2')); + } } diff --git a/src/Php/Backend/CompilerFactory.php b/src/Php/Backend/CompilerFactory.php index f5428ddf..a007887f 100644 --- a/src/Php/Backend/CompilerFactory.php +++ b/src/Php/Backend/CompilerFactory.php @@ -110,10 +110,69 @@ class CompilerFactory ]; } + public static function isCommandExecutable(string $command): bool + { + $program = self::getCommandProgram($command); + if ($program === '') { + return false; + } + + if (self::isPathLikeCommand($program)) { + return is_file($program) && is_executable($program); + } + + $path = getenv('PATH'); + if ($path === false || $path === '') { + return false; + } + + $extensions = ['']; + if (DIRECTORY_SEPARATOR === '\\') { + $pathext = getenv('PATHEXT') ?: '.COM;.EXE;.BAT;.CMD'; + $extensions = array_filter(array_map('strtolower', explode(';', $pathext))); + if (preg_match('/\.[A-Za-z0-9]+$/', $program)) { + array_unshift($extensions, ''); + } + } + + foreach (explode(PATH_SEPARATOR, $path) as $dir) { + if ($dir === '') { + continue; + } + foreach ($extensions as $extension) { + $candidate = rtrim($dir, DIRECTORY_SEPARATOR . '/\\') . DIRECTORY_SEPARATOR . $program . $extension; + if (is_file($candidate) && is_executable($candidate)) { + return true; + } + } + } + + return false; + } + + public static function getCommandProgram(string $command): string + { + $command = trim($command); + if ($command === '') { + return ''; + } + + if ($command[0] === '"' || $command[0] === "'") { + $quote = $command[0]; + $end = strpos($command, $quote, 1); + if ($end !== false) { + return substr($command, 1, $end - 1); + } + } + + $firstToken = strtok($command, " \t\r\n"); + return $firstToken === false ? '' : $firstToken; + } + private static function normalizeCompilerName(string $compilerName): string { - $firstToken = strtok(trim($compilerName), ' '); - if ($firstToken === false || $firstToken === '') { + $firstToken = self::getCommandProgram($compilerName); + if ($firstToken === '') { return ''; } @@ -122,4 +181,11 @@ class CompilerFactory return preg_replace('/\.exe$/', '', $name); } + + private static function isPathLikeCommand(string $program): bool + { + return str_contains($program, '/') + || str_contains($program, '\\') + || preg_match('/^[A-Za-z]:[\/\\\\]/', $program) === 1; + } } diff --git a/src/Php/Translator.php b/src/Php/Translator.php index 973a2d59..5ad650a0 100644 --- a/src/Php/Translator.php +++ b/src/Php/Translator.php @@ -636,6 +636,9 @@ class Translator extends Preprocessor public function prepare(string $path): array { + $files = $this->getFiles($path); + $this->validateCompilerToolchain(); + // shell_exec 和 define 已通过 php::fn:: 直接调用,无需动态符号表 // 根据平台检查库文件(仅在构建二进制文件时需要) @@ -648,7 +651,6 @@ class Translator extends Preprocessor } } - $files = $this->getFiles($path); $files = $this->filterIgnoredFiles($files); // 分析 PHP 文件,预处理 foreach ($files as $k => $file) { @@ -668,6 +670,30 @@ class Translator extends Preprocessor return $files; } + protected function validateCompilerToolchain(): void + { + $backend = $this->getCompilerBackend(); + $compilerCommand = $backend->getCompilerCommand(); + if (!CompilerFactory::isCommandExecutable($compilerCommand)) { + $program = CompilerFactory::getCommandProgram($compilerCommand); + $this->error( + "C/C++ compiler executable not found: {$program}\n" . + "Configured compiler command: {$compilerCommand}\n" . + "Install a supported compiler or set `cpp-compiler` in project.yml / PHPX_CC / CXX." + ); + } + + $linkerCommand = $backend->getLinkerCommand(); + if ($linkerCommand !== $compilerCommand && !CompilerFactory::isCommandExecutable($linkerCommand)) { + $program = CompilerFactory::getCommandProgram($linkerCommand); + $this->error( + "Linker executable not found: {$program}\n" . + "Configured linker command: {$linkerCommand}\n" . + "Install the required linker or update compiler configuration." + ); + } + } + protected function shouldIgnoreFile(string $file): bool { foreach ($this->ignorePaths as $ignorePath) {