- 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