From 4339ab56fcbd81a422064d692c5d9bc945b9e77a Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 4 Sep 2026 09:44:28 +0800 Subject: [PATCH] feat(cli): add compiler option and improve completion for tpc - Added --compiler option to specify C++ compiler command - Enhanced bash completion to suggest commands for --compiler option - Updated help text to document new --compiler option - Refactored compiler detection logic to prioritize --compiler over env vars - Added target platform support checking for compilers - Improved full-static build compiler resolution with better error handling - Fixed musl library directory lookup to use bundled SDK location - Added C++ runtime library linking for Linux/macOS builds - Updated CLI argument parsing and help display accordingly --- completions/tpc.bash | 11 +++- phpunit/BashCompletionTest.php | 32 ++++++++++ src/Backend/CompilerFactory.php | 61 ++++++++++++------ src/Build/NativeBuildConfigurationTrait.php | 47 +++++--------- src/Build/SourcePipelineTrait.php | 2 +- src/Cli/BashCompletion.php | 13 ++++ src/Cli/CompletionMetadata.php | 18 ++++++ src/Metadata/Constants.php | 6 ++ src/Translator.php | 69 +++++++++++++++++++-- 9 files changed, 205 insertions(+), 54 deletions(-) diff --git a/completions/tpc.bash b/completions/tpc.bash index 296a7f2d..05f1aab7 100644 --- a/completions/tpc.bash +++ b/completions/tpc.bash @@ -94,6 +94,10 @@ _typephp_tpc() _typephp_tpc_complete_paths -f "$current" return ;; + --compiler) + _typephp_tpc_complete_paths -c "$current" + return + ;; esac case "$current" in @@ -134,8 +138,13 @@ _typephp_tpc() done < <(compgen -d -- "$value") return ;; + --compiler=*) + value="${current#--compiler=}" + COMPREPLY=( $(compgen -c -P '--compiler=' -- "$value") ) + return + ;; -* ) - COMPREPLY=( $(compgen -W '-O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -r --run --debug -j --job --no-console --sanitize --cxx-std --march --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) + COMPREPLY=( $(compgen -W '-O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) return ;; esac diff --git a/phpunit/BashCompletionTest.php b/phpunit/BashCompletionTest.php index 550cf758..a670ed5f 100644 --- a/phpunit/BashCompletionTest.php +++ b/phpunit/BashCompletionTest.php @@ -71,6 +71,38 @@ final class BashCompletionTest extends TestCase ); } + /** + * --compiler must complete commands, not the default *.php/*.yml file set. + * + * Prefixes are chosen to exist on any POSIX box: "ec" matches the bash + * builtin `echo`, and /usr/bin/ always holds executables. + */ + public function testCompilerOptionCompletesCommandsAndExecutablePaths(): void + { + $script = TYPEPHP_ROOT_PATH . '/completions/tpc.bash'; + + $names = $this->complete($script, ['tpc', '--compiler', 'ec']); + self::assertNotSame('', $names); + self::assertStringContainsString('echo', $names); + foreach (explode("\n", trim($names)) as $candidate) { + self::assertDoesNotMatchRegularExpression('/\.(php|yml|yaml|prof)$/', $candidate); + } + + // Once the word contains a slash, completion switches to that directory. + $paths = $this->complete($script, ['tpc', '--compiler', '/usr/bin/']); + self::assertNotSame('', $paths); + foreach (explode("\n", trim($paths)) as $candidate) { + self::assertStringStartsWith('/usr/bin/', $candidate); + } + + // The --compiler= form keeps the prefix on every candidate. + $equals = $this->complete($script, ['tpc', '--compiler=ec']); + self::assertNotSame('', $equals); + foreach (explode("\n", trim($equals)) as $candidate) { + self::assertStringStartsWith('--compiler=', $candidate); + } + } + /** @param list $words */ private function complete(string $script, array $words): string { diff --git a/src/Backend/CompilerFactory.php b/src/Backend/CompilerFactory.php index c2eee671..0c472ba3 100644 --- a/src/Backend/CompilerFactory.php +++ b/src/Backend/CompilerFactory.php @@ -33,27 +33,11 @@ class CompilerFactory } /** - * Resolve the compiler command based on configuration, environment variables, and platform defaults. + * Resolve the compiler command from an explicit selection, falling back to the platform default. */ public static function detectCompilerName(PlatformBase $platform, string $configuredCompiler = ''): string { - if ($configuredCompiler !== '') { - return $configuredCompiler; - } - - $compilerEnv = getenv('PHPX_CC'); - if ($compilerEnv) { - return $compilerEnv; - } - - if (!$platform instanceof Windows) { - $cxxEnv = getenv('CXX'); - if ($cxxEnv) { - return $cxxEnv; - } - } - - return $platform->getDefaultCompiler(); + return $configuredCompiler !== '' ? $configuredCompiler : $platform->getDefaultCompiler(); } /** @@ -150,6 +134,47 @@ class CompilerFactory return false; } + /** + * Whether a compiler can actually build code for the given target triple. + * + * gcc rejects --target outright, and a wasm-only clang (wasi-sdk is often + * first on PATH in this project) accepts the flag but only fails once it + * has to create a target machine, so the probe must reach code generation. + */ + public static function supportsTarget(string $compilerName, string $target): bool + { + if (!self::isCommandExecutable($compilerName)) { + return false; + } + + $token = @tempnam(sys_get_temp_dir(), 'tpc_target_probe'); + if ($token === false) { + return false; + } + $source = $token . '.c'; + $object = $token . '.o'; + if (@file_put_contents($source, "int main(void) { return 0; }\n") === false) { + @unlink($token); + return false; + } + + $nullDevice = DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null'; + $command = escapeshellcmd($compilerName) + . ' ' . escapeshellarg('--target=' . $target) + . ' -c ' . escapeshellarg($source) + . ' -o ' . escapeshellarg($object) + . ' >' . $nullDevice . ' 2>&1'; + + $status = 0; + @exec($command, $output, $status); + + @unlink($source); + @unlink($object); + @unlink($token); + + return $status === 0; + } + public static function getCommandProgram(string $command): string { $command = trim($command); diff --git a/src/Build/NativeBuildConfigurationTrait.php b/src/Build/NativeBuildConfigurationTrait.php index 76808b01..a479c9ad 100644 --- a/src/Build/NativeBuildConfigurationTrait.php +++ b/src/Build/NativeBuildConfigurationTrait.php @@ -57,41 +57,20 @@ trait NativeBuildConfigurationTrait * thread-local read (ZTS globals, and PHP's ZEND_TLS data) lands on the * wrong address and the process crashes during php_module_startup. * - * Resolution order: PHPX_MUSL_LIB_DIR, the bundled SDK, then the usual - * system locations. + * The startup files ship with the SDK, at phpx/full-static/sdk/lib/musl. */ protected function getFullStaticMuslDir(): string { $sdkDir = $this->getFullStaticSdkDir(); - $arch = explode('-', $this->getFullStaticTargetTriple())[0]; - - $candidates = []; - $env = getenv('PHPX_MUSL_LIB_DIR'); - if (is_string($env) && $env !== '') { - $candidates[] = $env; - } - if ($sdkDir !== null) { - $candidates[] = $sdkDir . '/lib/musl'; - } - // Alpine and other musl-native systems keep the startup files in /usr/lib - $candidates[] = '/usr/lib/' . $arch . '-linux-musl'; - $candidates[] = '/usr/lib/' . $arch . '-alpine-linux-musl'; - $candidates[] = '/usr/lib/musl'; - $candidates[] = '/usr/local/musl/lib'; - $candidates[] = '/usr/lib'; - - foreach ($candidates as $dir) { - if (is_file($dir . '/crt1.o')) { - return $dir; - } + $muslDir = $sdkDir . '/lib/musl'; + if (!is_file($muslDir . '/crt1.o')) { + $this->error( + "--full-static requires the musl startup files bundled with the SDK;\n" + . " crt1.o not found at: {$muslDir}\n" + . ' Rebuild the SDK with sapi/scripts/build-sdk.sh, which now copies them there.' + ); } - - $this->error( - "--full-static requires musl startup files (crt1.o).\n" - . " Looked in: " . implode(', ', $candidates) . "\n" - . " Install musl (e.g. 'apt install musl-dev'), or copy crt1.o/crti.o/crtn.o from the\n" - . ' swoole-cli build container into ' . ($sdkDir ?? '') . "/lib/musl, or set PHPX_MUSL_LIB_DIR." - ); + return $muslDir; } protected function getIncludePaths(): array @@ -226,6 +205,14 @@ trait NativeBuildConfigurationTrait $libraries[] = 'gmp'; $libraries[] = 'gmpxx'; $libraries[] = 'mpfr'; + // The C++ runtime has to be requested explicitly: libphp.so carries + // none, and a C driver (clang) does not add it the way g++/clang++ + // do. WASI needs nothing — its toolchain supplies the runtime. + if ($this->isLinux()) { + $libraries[] = 'stdc++'; + } elseif ($this->isMacos()) { + $libraries[] = 'c++'; + } } return $libraries; diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index fea413bd..fe60f05f 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -175,7 +175,7 @@ trait SourcePipelineTrait $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." + "Install a supported compiler, or select one with --compiler, or set `cpp-compiler` in project.yml." ); } diff --git a/src/Cli/BashCompletion.php b/src/Cli/BashCompletion.php index 562c73f0..f3672c79 100644 --- a/src/Cli/BashCompletion.php +++ b/src/Cli/BashCompletion.php @@ -22,6 +22,7 @@ final class BashCompletion $directoryOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::directoryOptions())); $pythonFileOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::pythonFileOptions())); $outputFileOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::outputFileOptions())); + $commandOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::commandOptions())); $equalsCases = ''; foreach (CompletionMetadata::values() as $option => $values) { if (!str_ends_with($option, '=')) { @@ -45,6 +46,14 @@ final class BashCompletion . ' done < <(compgen -d -- "$value")' . "\n" . " return\n ;;\n"; } + foreach (CompletionMetadata::commandEqualsOptions() as $option) { + $prefix = substr($option, 0, -1); + $equalsCases .= ' ' . self::bashPattern($prefix) . "=*)\n" + . ' value="${current#' . $prefix . '=}"' . "\n" + . ' COMPREPLY=( $(compgen -c -P ' . self::quote($prefix . '=') + . ' -- "$value") )' . "\n" + . " return\n ;;\n"; + } $script = << + */ + public static function commandOptions(): array + { + return ['--compiler']; + } + + /** @return list */ + public static function commandEqualsOptions(): array + { + return ['--compiler=']; + } + /** @return list */ public static function outputFileOptions(): array { diff --git a/src/Metadata/Constants.php b/src/Metadata/Constants.php index 5d527e75..31f5cbec 100644 --- a/src/Metadata/Constants.php +++ b/src/Metadata/Constants.php @@ -232,6 +232,12 @@ class Constants 'required' => false, 'defaultValue' => '', ], + 'compiler' => [ + 'longPrefix' => 'compiler', + 'description' => 'C++ compiler command to use (e.g. --compiler=/usr/bin/clang)', + 'required' => false, + 'defaultValue' => '', + ], 'target-platform' => [ 'longPrefix' => 'target-platform', 'description' => 'Cross-compilation target triple (e.g. aarch64-linux-gnu, x86_64-w64-mingw32)', diff --git a/src/Translator.php b/src/Translator.php index b3f65c44..4b5f7460 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -214,20 +214,21 @@ class Translator extends Preprocessor } else { $this->platform = PlatformFactory::create(); } + // --compiler wins over the platform default; WASI keeps its own + // default because that platform also drives --sysroot and friends. $this->cppCompiler = $this->platform instanceof Wasi ? $this->platform->getDefaultCompiler() - : CompilerFactory::detectCompilerName($this->platform); + : CompilerFactory::detectCompilerName($this->platform, $this->getCommandLineCompiler()); // --full-static: libphp.a embeds musl libc, so the link must produce a // musl binary. That is driven by clang's --target=-unknown-linux-musl // plus -static -B , which gcc does not accept; a glibc link // would let musl's __libc_start_main install a thread pointer whose // layout does not match the linker's TLS offsets, corrupting every - // thread-local read in PHP's ZTS globals. PHPX_CC/CXX take precedence, - // otherwise fall back to clang. + // thread-local read in PHP's ZTS globals. if ($this->climate->arguments->defined('full-static')) { $this->fullStatic = true; - $this->cppCompiler = getenv('PHPX_CC') ?: (getenv('CXX') ?: 'clang'); + $this->cppCompiler = $this->detectFullStaticCompiler(); } if ($this->platform instanceof Windows) { @@ -259,6 +260,54 @@ class Translator extends Preprocessor } } + /** + * The compiler requested with --compiler, or '' when the flag was not given. + */ + protected function getCommandLineCompiler(): string + { + if (!$this->climate->arguments->defined('compiler')) { + return ''; + } + return trim((string) $this->climate->arguments->get('compiler')); + } + + /** + * Resolve a compiler able to build the fully-static musl target. + * + * Only a native clang can drive --target=-unknown-linux-musl, so the + * platform default (g++ on Linux) is never usable. --compiler is honoured + * but never silently replaced; otherwise PATH's first clang is tried and a + * standard location is used as a fallback — a wasm-only clang such as + * wasi-sdk sits first on PATH in this project and would otherwise fail + * halfway through the build with an opaque "no available targets" error. + */ + protected function detectFullStaticCompiler(): string + { + $target = $this->getFullStaticTargetTriple(); + + $explicit = $this->getCommandLineCompiler(); + if ($explicit !== '') { + if (!CompilerFactory::supportsTarget($explicit, $target)) { + $this->error( + "--full-static requires a clang that can target {$target}, but {$explicit} cannot.\n" + . " Pass a native clang, for example: --compiler=/usr/bin/clang" + ); + } + return $explicit; + } + + foreach (['clang', '/usr/bin/clang'] as $candidate) { + if (CompilerFactory::supportsTarget($candidate, $target)) { + return $candidate; + } + } + + $this->error( + "--full-static requires a clang that can target {$target}; none was found.\n" + . " Install clang, or pass it explicitly, for example: --compiler=/usr/bin/clang" + ); + } + public function parseArgv(array $argv) { $path = null; @@ -312,6 +361,7 @@ class Translator extends Preprocessor $climate->tab()->out('-r, --run Run the compiled binary after build'); $climate->tab()->out('-j, --job Number of parallel compilation jobs (default: 4)'); $climate->tab()->out('--cxx-std C++ standard version (c++17, c++20, etc., default: c++17)'); + $climate->tab()->out('--compiler C++ compiler command to use (e.g. --compiler=/usr/bin/clang)'); $climate->tab()->out('--march Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)'); $climate->tab()->out('--target-platform Cross-compilation target triple (e.g. aarch64-linux-gnu)'); $climate->tab()->out('--wasm[=profile] Build WASI component (default) or browser output'); @@ -332,6 +382,7 @@ class Translator extends Preprocessor $climate->tab()->out('--format Enable clang-format code formatting (disabled by default)'); $climate->tab()->out('-l, --link-lib Link against a library (repeatable, e.g. -lcurl)'); $climate->tab()->out('-L, --link-path Add a library search path (repeatable, e.g. -L/usr/local/lib)'); + $climate->tab()->out('--full-static Link fully statically against the bundled SDK (phpx/full-static/sdk)'); $climate->br(); } @@ -353,6 +404,16 @@ class Translator extends Preprocessor $this->setBuildMode($this->climate->arguments->get('mode')); } + // --full-static links the whole musl C runtime into the artifact, so it + // only makes sense for a self-contained executable. A shared object + // would drag a second, incompatible libc into the host process. + if ($this->fullStatic && !$this->isBuildModeBin()) { + $this->error( + '--full-static is only supported in bin mode (-m bin); ' + . 'shared libraries and extensions cannot embed a C runtime.' + ); + } + // Debug line number if ($this->climate->arguments->defined('debug-line')) { $this->debugLine = intval($this->climate->arguments->get('debug-line'));