- 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 textmaster
parent
6c9adbccad
commit
a510530d9b
7 changed files with 405 additions and 0 deletions
@ -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=']; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue