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
master
韩天峰 2 weeks ago
parent 6c9adbccad
commit a510530d9b
  1. 34
      docs/COMPILER_CLI.md
  2. 92
      phpunit/BashCompletionTest.php
  3. 164
      src/Cli/BashCompletion.php
  4. 26
      src/Cli/CompletionCommand.php
  5. 79
      src/Cli/CompletionMetadata.php
  6. 1
      src/Translator.php
  7. 9
      src/compiler.php

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

@ -0,0 +1,92 @@
<?php
use PHPUnit\Framework\TestCase;
use TypePhp\Cli\BashCompletion;
use TypePhp\Cli\CompletionCommand;
use TypePhp\Cli\CompletionMetadata;
use TypePhp\Metadata\Constants;
final class BashCompletionTest extends TestCase
{
public function testGeneratedFileMatchesRenderer(): void
{
self::assertSame(
BashCompletion::render(),
file_get_contents(ROOT_PATH . '/completions/tpc.bash'),
);
}
public function testEveryPublicCompilerOptionIsCompleted(): void
{
$options = CompletionMetadata::options();
foreach (Constants::COMPILER_OPTIONS as $name => $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<string> $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;
}
}

@ -0,0 +1,164 @@
<?php
namespace TypePhp\Cli;
final class BashCompletion
{
public static function render(): string
{
$options = self::words(CompletionMetadata::options());
$valueCases = '';
foreach (CompletionMetadata::values() as $option => $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 = <<<BASH
# Bash completion for the TypePHP compiler.
# Generated by: tpc --generate-completion=bash
_typephp_tpc_complete_files()
{
local candidate
COMPREPLY=()
while IFS= read -r candidate; do
if [[ -d "\$candidate" || "\$candidate" == *.php || "\$candidate" == *.yml || "\$candidate" == *.yaml || "\$candidate" == *.prof ]]; then
COMPREPLY+=("\$candidate")
fi
done < <(compgen -f -- "\$1")
}
_typephp_tpc_complete_python_files()
{
local candidate
COMPREPLY=()
while IFS= read -r candidate; do
if [[ -d "\$candidate" || "\$candidate" == *.py ]]; then
COMPREPLY+=("\$candidate")
fi
done < <(compgen -f -- "\$1")
}
_typephp_tpc_complete_paths()
{
local mode="\$1" candidate
COMPREPLY=()
shift
while IFS= read -r candidate; do
COMPREPLY+=("\$candidate")
done < <(compgen "\$mode" -- "\$1")
}
_typephp_tpc()
{
local current previous value candidate
current="\${COMP_WORDS[COMP_CWORD]}"
previous=""
if (( COMP_CWORD > 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<string> $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);
}
}

@ -0,0 +1,26 @@
<?php
namespace TypePhp\Cli;
final class CompletionCommand
{
/** Return null for a normal compiler invocation, otherwise an exit status. */
public static function execute(array $argv): ?int
{
$matches = array_values(array_filter(
array_slice($argv, 1),
static fn (mixed $argument): bool => 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;
}
}

@ -0,0 +1,79 @@
<?php
namespace TypePhp\Cli;
use TypePhp\Metadata\Constants;
final class CompletionMetadata
{
private const array HIDDEN_OPTIONS = ['debug-line'];
/** @return list<string> */
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<string, list<string>> */
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<string> */
public static function directoryOptions(): array
{
return ['--build-dir', '--output-dir', '-I', '--include-path', '-L', '--link-path'];
}
/** @return list<string> */
public static function pythonFileOptions(): array
{
return ['--convert-python-to-php'];
}
/** @return list<string> */
public static function outputFileOptions(): array
{
return ['-o', '--output'];
}
/** @return list<string> */
public static function directoryEqualsOptions(): array
{
return ['--build-dir=', '--output-dir='];
}
}

@ -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 <module> [--output-dir <dir>] Generate a Python namespace IDE helper');
$climate->tab()->out('--convert-python-to-php <file.py> 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 <ver> PHP language version to accept (8.2-8.5, default: 8.5)');

@ -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) {

Loading…
Cancel
Save