From a510530d9babcf2e610f17f8a47968bdc894fc5d Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Wed, 12 Aug 2026 21:34:47 +0800 Subject: [PATCH] feat(cli): add bash completion support - Implement BashCompletion class that generates bash completion script - Add CompletionCommand to handle --generate-completion=bash argument - Create CompletionMetadata to provide option and value definitions - Integrate completion check into main compiler flow - Add unit tests for bash completion generation and functionality - Update documentation with bash completion usage instructions - Add --generate-completion=bash to CLI help text --- docs/COMPILER_CLI.md | 34 +++++++ phpunit/BashCompletionTest.php | 92 ++++++++++++++++++ src/Cli/BashCompletion.php | 164 +++++++++++++++++++++++++++++++++ src/Cli/CompletionCommand.php | 26 ++++++ src/Cli/CompletionMetadata.php | 79 ++++++++++++++++ src/Translator.php | 1 + src/compiler.php | 9 ++ 7 files changed, 405 insertions(+) create mode 100644 phpunit/BashCompletionTest.php create mode 100644 src/Cli/BashCompletion.php create mode 100644 src/Cli/CompletionCommand.php create mode 100644 src/Cli/CompletionMetadata.php diff --git a/docs/COMPILER_CLI.md b/docs/COMPILER_CLI.md index 5a515da8..7e5c2cf4 100644 --- a/docs/COMPILER_CLI.md +++ b/docs/COMPILER_CLI.md @@ -1,5 +1,39 @@ # TypePHP 编译器命令行 +## Bash 自动补全 + +TypePHP 提供与当前编译器参数同步的 Bash completion。当前终端临时启用: + +```shell +source <(./tpc --generate-completion=bash) +``` + +从源码仓库开发时也可以直接执行 `source completions/tpc.bash`。 + +安装到当前用户并在后续 Bash 会话自动加载: + +```shell +mkdir -p "$HOME/.local/share/bash-completion/completions" +./tpc --generate-completion=bash \ + > "$HOME/.local/share/bash-completion/completions/tpc" +``` + +如果系统没有自动扫描用户 completion 目录,可在 `~/.bashrc` 中加载: + +```shell +source "$HOME/.local/share/bash-completion/completions/tpc" +``` + +系统级安装可将生成结果写入 `/usr/share/bash-completion/completions/tpc`。该操作通常 +需要 root 权限。 + +补全支持编译选项、WASM profile、构建模式、PHP/C++ 版本、sanitizer、输入源码、 +项目 YAML、Python 源文件以及目录参数。`--` 之后是被编译程序自身的参数,补全器 +不会再把它们解释为 `tpc` 参数。 + +发布包携带预生成的 `completions/tpc.bash`。此文件由同一个生成器产生,并有单元 +测试保证它与 `./tpc --generate-completion=bash` 的输出一致。 + 本文档与 `src/Translator.php::showUsage()` 保持同步。使用: ```bash diff --git a/phpunit/BashCompletionTest.php b/phpunit/BashCompletionTest.php new file mode 100644 index 00000000..35c6d8e9 --- /dev/null +++ b/phpunit/BashCompletionTest.php @@ -0,0 +1,92 @@ + $definition) { + if ($name === 'debug-line') { + continue; + } + if (isset($definition['prefix'])) { + self::assertContains('-' . $definition['prefix'], $options); + } + if (isset($definition['longPrefix'])) { + self::assertContains('--' . $definition['longPrefix'], $options); + } + } + } + + public function testCompletionCommandOnlyAcceptsBash(): void + { + self::assertNull(CompletionCommand::execute(['tpc', 'hello.php'])); + self::assertSame( + BashCompletion::render(), + $this->runBash( + escapeshellarg(PHP_BINARY) + . ' bin/tpc.php --generate-completion=bash', + ), + ); + } + + public function testBashScriptSyntaxAndRepresentativeCompletions(): void + { + $script = ROOT_PATH . '/completions/tpc.bash'; + self::assertSame('', $this->runBash("bash -n " . escapeshellarg($script))); + self::assertStringNotContainsString('mapfile', file_get_contents($script)); + self::assertSame( + "--wasm=component\n", + $this->complete($script, ['tpc', '--wasm=c']), + ); + self::assertSame( + "bin\nlib\next\n", + $this->complete($script, ['tpc', '--mode', '']), + ); + self::assertSame( + "8.4\n", + $this->complete($script, ['tpc', '--php-version', '8.4']), + ); + self::assertSame( + "-O2\n", + $this->complete($script, ['tpc', '-O2']), + ); + } + + /** @param list $words */ + private function complete(string $script, array $words): string + { + $assignments = []; + foreach ($words as $word) { + $assignments[] = escapeshellarg($word); + } + $command = 'source ' . escapeshellarg($script) + . '; compopt() { :; }' + . '; COMP_WORDS=(' . implode(' ', $assignments) . ')' + . '; COMP_CWORD=' . (count($words) - 1) + . '; _typephp_tpc' + . '; printf "%s\\n" "${COMPREPLY[@]}"'; + return $this->runBash('bash -c ' . escapeshellarg($command)); + } + + private function runBash(string $command): string + { + exec($command . ' 2>&1', $output, $status); + self::assertSame(0, $status, implode(PHP_EOL, $output)); + return $output === [] ? '' : implode(PHP_EOL, $output) . PHP_EOL; + } +} diff --git a/src/Cli/BashCompletion.php b/src/Cli/BashCompletion.php new file mode 100644 index 00000000..562c73f0 --- /dev/null +++ b/src/Cli/BashCompletion.php @@ -0,0 +1,164 @@ + $values) { + if (str_ends_with($option, '=')) { + continue; + } + $valueCases .= ' ' . self::bashPattern($option) . ")\n" + . ' COMPREPLY=( $(compgen -W ' + . self::quote(self::words($values)) . ' -- "$current") )' . "\n" + . " return\n ;;\n"; + } + $quotedOptions = self::quote($options); + + $directoryOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::directoryOptions())); + $pythonFileOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::pythonFileOptions())); + $outputFileOptions = implode('|', array_map(self::bashPattern(...), CompletionMetadata::outputFileOptions())); + $equalsCases = ''; + foreach (CompletionMetadata::values() as $option => $values) { + if (!str_ends_with($option, '=')) { + continue; + } + $prefix = substr($option, 0, -1); + $equalsCases .= ' ' . self::bashPattern($prefix) . "=*)\n" + . ' value="${current#' . $prefix . '=}"' . "\n" + . ' COMPREPLY=( $(compgen -W ' . self::quote(self::words($values)) + . ' -P ' . self::quote($prefix . '=') . ' -- "$value") )' . "\n" + . " return\n ;;\n"; + } + foreach (CompletionMetadata::directoryEqualsOptions() as $option) { + $prefix = substr($option, 0, -1); + $equalsCases .= ' ' . self::bashPattern($prefix) . "=*)\n" + . ' value="${current#' . $prefix . '=}"' . "\n" + . " compopt -o filenames\n" + . ' COMPREPLY=()' . "\n" + . ' while IFS= read -r candidate; do' . "\n" + . ' COMPREPLY+=("' . $prefix . '=${candidate}")' . "\n" + . ' done < <(compgen -d -- "$value")' . "\n" + . " return\n ;;\n"; + } + + $script = << 0 )); then + previous="\${COMP_WORDS[COMP_CWORD - 1]}" + fi + + local index + for ((index = 1; index < COMP_CWORD; index++)); do + if [[ "\${COMP_WORDS[index]}" == -- ]]; then + compopt -o default + return + fi + done + + case "\$previous" in +{$valueCases} {$directoryOptions}) + compopt -o filenames + _typephp_tpc_complete_paths -d "\$current" + return + ;; + {$pythonFileOptions}) + compopt -o filenames + _typephp_tpc_complete_python_files "\$current" + return + ;; + {$outputFileOptions}) + compopt -o filenames + _typephp_tpc_complete_paths -f "\$current" + return + ;; + esac + + case "\$current" in + -O[0-3]) + COMPREPLY=("\$current") + return + ;; + -O*) + value="\${current#-O}" + COMPREPLY=( $(compgen -W '0 1 2 3' -P '-O' -- "\$value") ) + return + ;; +{$equalsCases} -* ) + COMPREPLY=( $(compgen -W {$quotedOptions} -- "\$current") ) + return + ;; + esac + + compopt -o filenames + _typephp_tpc_complete_files "\$current" +} + +complete -F _typephp_tpc tpc +complete -F _typephp_tpc ./tpc +complete -F _typephp_tpc tpc.php +complete -F _typephp_tpc bin/tpc.php +complete -F _typephp_tpc ./bin/tpc.php +BASH; + return $script . PHP_EOL; + } + + /** @param list $words */ + private static function words(array $words): string + { + return implode(' ', $words); + } + + private static function quote(string $value): string + { + return "'" . str_replace("'", "'\\''", $value) . "'"; + } + + private static function bashPattern(string $value): string + { + return str_replace(['\\', '*', '?', '[', ']'], ['\\\\', '\\*', '\\?', '\\[', '\\]'], $value); + } +} diff --git a/src/Cli/CompletionCommand.php b/src/Cli/CompletionCommand.php new file mode 100644 index 00000000..691f162f --- /dev/null +++ b/src/Cli/CompletionCommand.php @@ -0,0 +1,26 @@ + is_string($argument) + && str_starts_with($argument, '--generate-completion'), + )); + if ($matches === []) { + return null; + } + if (count($argv) !== 2 || count($matches) !== 1 || $matches[0] !== '--generate-completion=bash') { + fwrite(STDERR, "Usage: {$argv[0]} --generate-completion=bash" . PHP_EOL); + return 1; + } + + fwrite(STDOUT, BashCompletion::render()); + return 0; + } +} diff --git a/src/Cli/CompletionMetadata.php b/src/Cli/CompletionMetadata.php new file mode 100644 index 00000000..b86b3550 --- /dev/null +++ b/src/Cli/CompletionMetadata.php @@ -0,0 +1,79 @@ + */ + public static function options(): array + { + $options = []; + foreach (Constants::COMPILER_OPTIONS as $name => $definition) { + if (in_array($name, self::HIDDEN_OPTIONS, true)) { + continue; + } + if (isset($definition['prefix'])) { + $options[] = '-' . $definition['prefix']; + } + if (isset($definition['longPrefix'])) { + $options[] = '--' . $definition['longPrefix']; + } + } + + return array_values(array_unique([ + ...$options, + '--wasm', + '--wasm=', + '--gen-python-helper', + '--convert-python-to-php', + '--output-dir', + '--output-dir=', + '--build-dir=', + '--generate-completion=', + ])); + } + + /** @return array> */ + public static function values(): array + { + return [ + '-O' => ['0', '1', '2', '3'], + '--optimize' => ['0', '1', '2', '3'], + '-m' => ['bin', 'lib', 'ext'], + '--mode' => ['bin', 'lib', 'ext'], + '--php-version' => ['8.2', '8.3', '8.4', '8.5'], + '--cxx-std' => ['c++17', 'c++20', 'c++23'], + '--sanitize' => ['address', 'undefined'], + '--wasm=' => ['component', 'browser'], + '--generate-completion=' => ['bash'], + ]; + } + + /** @return list */ + public static function directoryOptions(): array + { + return ['--build-dir', '--output-dir', '-I', '--include-path', '-L', '--link-path']; + } + + /** @return list */ + public static function pythonFileOptions(): array + { + return ['--convert-python-to-php']; + } + + /** @return list */ + public static function outputFileOptions(): array + { + return ['-o', '--output']; + } + + /** @return list */ + public static function directoryEqualsOptions(): array + { + return ['--build-dir=', '--output-dir=']; + } +} diff --git a/src/Translator.php b/src/Translator.php index 70d0b132..f2839e4e 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -274,6 +274,7 @@ class Translator extends Preprocessor $climate->tab()->out('--wasm[=profile] Build WASI component (default) or browser output'); $climate->tab()->out('--gen-python-helper [--output-dir ] Generate a Python namespace IDE helper'); $climate->tab()->out('--convert-python-to-php Convert Python source to TypePHP source'); + $climate->tab()->out('--generate-completion=bash Generate Bash completion script'); $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)'); diff --git a/src/compiler.php b/src/compiler.php index dedafa48..b3c01cb0 100644 --- a/src/compiler.php +++ b/src/compiler.php @@ -4,6 +4,7 @@ use TypePhp\Build\WasiToolchain; use TypePhp\Build\WasiProjectConfig; use TypePhp\Build\PhpxLocator; use TypePhp\PythonTools\Command as PythonToolsCommand; +use TypePhp\Cli\CompletionCommand; function main(int $argc, array $argv): void { @@ -15,6 +16,14 @@ function main(int $argc, array $argv): void define("ROOT_PATH", getcwd()); } + $completionStatus = CompletionCommand::execute($argv); + if ($completionStatus !== null) { + if ($completionStatus !== 0) { + exit($completionStatus); + } + return; + } + $pythonToolStatus = PythonToolsCommand::execute($argv); if ($pythonToolStatus !== null) { if ($pythonToolStatus !== 0) {