From 82d68e0f21200ab92dff492045154b6a9540900a Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 12:00:41 +0100 Subject: [PATCH 01/30] chore: add completion-helper ownership to CODEOWNERS --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7070e39c6..081c9c2dc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ /packages/clock/ @aidan-casey /packages/command-bus/ @brendt @aidan-casey /packages/console/ @brendt +/packages/console/completion-helper/ @xHeaven /packages/container/ @brendt /packages/core/ @brendt /packages/cryptography/ @innocenzi From 3633d4723c5640bd25bc6bead28f08637a9290a6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 12:00:47 +0100 Subject: [PATCH 02/30] feat(console): add CompletionRuntime class --- packages/console/src/CompletionRuntime.php | 103 ++++++++++++++++++ .../console/tests/CompletionRuntimeTest.php | 56 ++++++++++ 2 files changed, 159 insertions(+) create mode 100644 packages/console/src/CompletionRuntime.php create mode 100644 packages/console/tests/CompletionRuntimeTest.php diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php new file mode 100644 index 000000000..5f4ebee00 --- /dev/null +++ b/packages/console/src/CompletionRuntime.php @@ -0,0 +1,103 @@ +toString()); + } + + public static function getDirectory(): string + { + return internal_storage_path('completion'); + } + + public static function getMetadataPath(): string + { + return internal_storage_path('completion', 'commands.json'); + } + + public static function getHelperBinaryPath(): string + { + return internal_storage_path('completion', self::getHelperBinaryFilename()); + } + + public static function getBundledHelperBinaryPath(): string + { + return Path::canonicalize( + path(__DIR__, '..', 'bin', self::getBundledHelperBinaryFilename())->toString(), + ); + } + + public static function isSupportedPlatform(?string $osFamily = null): bool + { + return match ($osFamily ?? PHP_OS_FAMILY) { + 'Darwin', 'Linux' => true, + default => false, + }; + } + + public static function getUnsupportedPlatformMessage(): string + { + return 'Completion commands are supported on Linux and macOS. Use WSL if you are on Windows.'; + } + + public static function getHelperBinaryPlatform(): string + { + $os = match (PHP_OS_FAMILY) { + 'Darwin' => 'darwin', + 'Linux' => 'linux', + default => strtolower(PHP_OS_FAMILY), + }; + + $architecture = match (strtolower((string) php_uname('m'))) { + 'amd64', 'x86_64' => 'x86_64', + 'aarch64', 'arm64' => 'arm64', + default => strtolower((string) php_uname('m')), + }; + + return "{$os}-{$architecture}"; + } + + public static function getHelperBinaryFilename(): string + { + return 'tempest-complete'; + } + + public static function getBundledHelperBinaryFilename(): string + { + return self::getHelperBinaryFilename() . '_' . str_replace('-', '_', self::getHelperBinaryPlatform()); + } + + private static function getProfileDirectory(): string + { + $profileDirectory = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? getenv('HOME') ?: null; + + if ($profileDirectory === null || $profileDirectory === '') { + $profileDirectory = $_SERVER['USERPROFILE'] ?? $_ENV['USERPROFILE'] ?? getenv('USERPROFILE') ?: null; + } + + if (($profileDirectory === null || $profileDirectory === '') && getenv('HOMEDRIVE') !== false && getenv('HOMEPATH') !== false) { + $profileDirectory = getenv('HOMEDRIVE') . getenv('HOMEPATH'); + } + + if ($profileDirectory === null || $profileDirectory === '') { + throw new RuntimeException('Could not determine user profile directory for completions.'); + } + + return $profileDirectory; + } +} diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php new file mode 100644 index 000000000..4d9c08361 --- /dev/null +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -0,0 +1,56 @@ +assertSame($expected, CompletionRuntime::isSupportedPlatform($osFamily)); + } + + public static function supportedPlatformDataProvider(): array + { + return [ + 'linux' => ['Linux', true], + 'darwin' => ['Darwin', true], + 'windows' => ['Windows', false], + ]; + } + + #[Test] + public function getUnsupportedPlatformMessage(): void + { + $this->assertStringContainsString('Windows', CompletionRuntime::getUnsupportedPlatformMessage()); + } + + #[Test] + public function getBundledHelperBinaryFilename(): void + { + $this->assertMatchesRegularExpression( + '/^tempest-complete_[a-z0-9]+_[a-z0-9_]+$/', + CompletionRuntime::getBundledHelperBinaryFilename(), + ); + } + + #[Test] + public function getBundledHelperBinaryPath(): void + { + $path = str_replace('\\', '/', CompletionRuntime::getBundledHelperBinaryPath()); + + $this->assertStringContainsString('/packages/console/bin/', $path); + $this->assertStringEndsWith('/' . CompletionRuntime::getBundledHelperBinaryFilename(), $path); + } +} From ce5c69c077c27139650539f4ecca24326d16e20f Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 12:00:58 +0100 Subject: [PATCH 03/30] feat(console): add completion metadata and helper actions --- .../src/Actions/BuildCompletionMetadata.php | 103 ++++++++++++++++++ .../Actions/EnsureCompletionHelperBinary.php | 69 ++++++++++++ .../src/Actions/RenderCompletionScript.php | 25 +++++ 3 files changed, 197 insertions(+) create mode 100644 packages/console/src/Actions/BuildCompletionMetadata.php create mode 100644 packages/console/src/Actions/EnsureCompletionHelperBinary.php create mode 100644 packages/console/src/Actions/RenderCompletionScript.php diff --git a/packages/console/src/Actions/BuildCompletionMetadata.php b/packages/console/src/Actions/BuildCompletionMetadata.php new file mode 100644 index 000000000..ba7089001 --- /dev/null +++ b/packages/console/src/Actions/BuildCompletionMetadata.php @@ -0,0 +1,103 @@ +consoleConfig->commands as $name => $command) { + $flags = array_map( + static fn (ConsoleArgumentDefinition $definition): array => [ + 'name' => $definition->name, + 'flag' => self::buildFlagNotation($definition), + 'aliases' => self::buildFlagAliases($definition), + 'description' => $definition->description, + 'value_options' => self::buildValueOptions($definition), + 'repeatable' => $definition->type === 'array' || $definition->isVariadic, + 'requires_value' => $definition->type !== 'bool', + ], + $command->getArgumentDefinitions(), + ); + + usort($flags, static fn (array $a, array $b): int => $a['flag'] <=> $b['flag']); + + $commands[$name] = [ + 'hidden' => $command->hidden, + 'description' => $command->description, + 'flags' => $flags, + ]; + } + + ksort($commands); + + return [ + 'version' => 1, + 'commands' => $commands, + ]; + } + + private static function buildFlagNotation(ConsoleArgumentDefinition $definition): string + { + $flag = "--{$definition->name}"; + + if ($definition->type !== 'bool') { + $flag .= '='; + } + + return $flag; + } + + private static function buildFlagAliases(ConsoleArgumentDefinition $definition): array + { + $aliases = array_values(array_filter(array_map(static function (string $alias): ?string { + $normalized = ltrim(str($alias)->trim()->kebab()->toString(), '-'); + + if ($normalized === '') { + return null; + } + + return match (strlen($normalized)) { + 1 => "-{$normalized}", + default => "--{$normalized}", + }; + }, $definition->aliases))); + + sort($aliases); + + return $aliases; + } + + private static function buildValueOptions(ConsoleArgumentDefinition $definition): array + { + if (! $definition->isBackedEnum()) { + return []; + } + + /** @var BackedEnum $type */ + $type = $definition->type; + + $options = array_map( + static fn (BackedEnum $case): string => (string) $case->value, + $type::cases(), + ); + + sort($options); + + return $options; + } +} diff --git a/packages/console/src/Actions/EnsureCompletionHelperBinary.php b/packages/console/src/Actions/EnsureCompletionHelperBinary.php new file mode 100644 index 000000000..60efa32b7 --- /dev/null +++ b/packages/console/src/Actions/EnsureCompletionHelperBinary.php @@ -0,0 +1,69 @@ +hasMatchingHash($binaryPath, $bundledBinaryPath)) { + $mustCopyBinary = false; + } + + if (! $mustCopyBinary && Filesystem\is_executable($binaryPath)) { + return $binaryPath; + } + } + + if ($mustCopyBinary) { + Filesystem\ensure_directory_exists(dirname($binaryPath)); + Filesystem\copy_file($bundledBinaryPath, $binaryPath, overwrite: true); + } + + chmod($binaryPath, 0o755); + + if (Filesystem\is_executable($binaryPath)) { + return $binaryPath; + } + + throw new RuntimeException("Completion helper binary could not be made executable: {$binaryPath}"); + } + + private function hasMatchingHash(string $runtimeBinaryPath, string $bundledBinaryPath): bool + { + if (! Filesystem\is_readable($runtimeBinaryPath) || ! Filesystem\is_readable($bundledBinaryPath)) { + return false; + } + + $runtimeHash = hash_file('xxh128', $runtimeBinaryPath); + $bundledHash = hash_file('xxh128', $bundledBinaryPath); + + if (! is_string($runtimeHash) || ! is_string($bundledHash)) { + return false; + } + + return hash_equals($runtimeHash, $bundledHash); + } +} diff --git a/packages/console/src/Actions/RenderCompletionScript.php b/packages/console/src/Actions/RenderCompletionScript.php new file mode 100644 index 000000000..88796dc12 --- /dev/null +++ b/packages/console/src/Actions/RenderCompletionScript.php @@ -0,0 +1,25 @@ + Date: Fri, 13 Feb 2026 12:01:03 +0100 Subject: [PATCH 04/30] feat(console): rewrite shell completion scripts --- packages/console/src/completion.bash | 213 +++++++++++++++++++++++---- packages/console/src/completion.zsh | 185 +++++++++++++++++------ 2 files changed, 322 insertions(+), 76 deletions(-) diff --git a/packages/console/src/completion.bash b/packages/console/src/completion.bash index b700571e4..1fe6b162b 100644 --- a/packages/console/src/completion.bash +++ b/packages/console/src/completion.bash @@ -1,45 +1,194 @@ -# Tempest Framework Bash Completion -# Supports: ./tempest, tempest, php tempest, php vendor/bin/tempest, etc. +_tempest_project_directory() { + local command="$1" + local command_directory -_tempest() { - local cur tempest_cmd shift_count input_args output IFS + command_directory="$(dirname "$command")" - # Initialize current word (use bash-completion if available) - if declare -F _init_completion >/dev/null 2>&1; then - _init_completion || return - else - cur="${COMP_WORDS[COMP_CWORD]}" + if [[ "$command" == "vendor/bin/tempest" || "$command" == */vendor/bin/tempest ]]; then + cd "$command_directory/../.." 2>/dev/null && pwd -P + return fi - # Detect invocation pattern and build command - if [[ "${COMP_WORDS[0]}" == "php" ]]; then - tempest_cmd="${COMP_WORDS[0]} ${COMP_WORDS[1]}" - shift_count=2 - else - tempest_cmd="${COMP_WORDS[0]}" - shift_count=1 + cd "$command_directory" 2>/dev/null && pwd -P +} + +COMP_WORDBREAKS="${COMP_WORDBREAKS//:}" + +_tempest_php_passthrough() { + local fallback_function + + [[ -n "${__tempest_php_original_completion:-}" ]] || return 1 + + if [[ "$__tempest_php_original_completion" =~ -F[[:space:]]+([^[:space:]]+) ]]; then + fallback_function="${BASH_REMATCH[1]}" + + if [[ "$fallback_function" != "_tempest" ]] && declare -F "$fallback_function" >/dev/null 2>&1; then + "$fallback_function" + return $? + fi fi - # Build _complete arguments, skipping "php" prefix if present - input_args="--current=$((COMP_CWORD - shift_count + 1))" - for ((i = shift_count - 1; i < ${#COMP_WORDS[@]}; i++)); do - input_args+=" --input=\"${COMP_WORDS[i]}\"" + return 1 +} + +_tempest_has_compopt() { + type compopt >/dev/null 2>&1 +} + +_tempest_disable_default_completion() { + _tempest_has_compopt || return 0 + + compopt +o default +o bashdefault +} + +_tempest_enable_nospace_for_assignment_candidates() { + local candidate + + _tempest_has_compopt || return 0 + + for candidate in "${COMPREPLY[@]}"; do + if [[ "$candidate" == *= ]]; then + compopt -o nospace + break + fi done +} - # Execute completion command - output=$(eval "$tempest_cmd _complete $input_args" 2>/dev/null) || return 0 - [[ -z "$output" ]] && return 0 +_tempest_resolve_command() { + local -a line_words=("$@") + local command command_name passthrough_status + + if [[ "${line_words[0]}" == "php" ]]; then + if (( ${#line_words[@]} < 2 )) || [[ -z "${line_words[1]}" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi + + command="${line_words[1]}" + command_name="${command##*/}" + + if [[ "$command_name" != "tempest" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi + else + command="${line_words[0]}" + command_name="${command##*/}" + fi + + [[ "$command_name" == "tempest" ]] || return 2 + + REPLY="$command" + return 0 +} + +_tempest_segment_prefix() { + local current_word="$1" + local current_segment="$2" - # Parse and filter completions - IFS=$'\n' - COMPREPLY=($(compgen -W "$output" -- "$cur")) + REPLY='' - # Suppress trailing space for flags expecting values (bash 4.0+) - if [[ ${#COMPREPLY[@]} -eq 1 && "${COMPREPLY[0]}" == *= ]] && type compopt &>/dev/null; then - compopt -o nospace + # COMP_WORDS may point at only the segment being completed around '=' or ':'. + if [[ "$current_word" == *[=:]* ]] && [[ "$current_segment" != "$current_word" ]]; then + if [[ -z "$current_segment" || "$current_segment" == "=" ]]; then + REPLY="$current_word" + elif [[ -n "$current_segment" ]]; then + REPLY="${current_word%"$current_segment"}" + fi fi } -complete -F _tempest ./tempest -complete -F _tempest tempest -complete -F _tempest php +_tempest_collect_candidates() { + local output="$1" + local segment_prefix="$2" + local candidate tab + + COMPREPLY=() + + tab=$'\t' + + while IFS= read -r candidate; do + if [[ "$candidate" == *"$tab"* ]]; then + candidate="${candidate%%$tab*}" + fi + + if [[ -n "$segment_prefix" ]] && [[ "$candidate" == "$segment_prefix"* ]]; then + candidate="${candidate#$segment_prefix}" + fi + + [[ -z "$candidate" ]] && continue + COMPREPLY+=("$candidate") + done <<< "$output" +} + +_tempest() { + local command project_directory helper metadata output line_prefix current_index current_word current_segment segment_prefix status + local -a line_words + + line_prefix="${COMP_LINE:0:COMP_POINT}" + read -r -a line_words <<< "$line_prefix" + + if [[ "$line_prefix" == *[[:space:]] ]]; then + line_words+=("") + fi + + (( ${#line_words[@]} > 0 )) || return 0 + + _tempest_resolve_command "${line_words[@]}" + status=$? + + case $status in + 0) + command="$REPLY" + ;; + 3) + return "$REPLY" + ;; + *) + return 0 + ;; + esac + + local COMP_WORDBREAKS="${COMP_WORDBREAKS//:}" + + project_directory="$(_tempest_project_directory "$command")" || return 0 + + helper="$project_directory/.tempest/completion/tempest-complete" + metadata="$project_directory/.tempest/completion/commands.json" + + [[ -x "$helper" ]] || return 0 + [[ -f "$metadata" ]] || return 0 + + _tempest_disable_default_completion + + current_index=$(( ${#line_words[@]} - 1 )) + current_word="${line_words[$current_index]}" + current_segment="${COMP_WORDS[COMP_CWORD]:-}" + + _tempest_segment_prefix "$current_word" "$current_segment" + segment_prefix="$REPLY" + + output="$($helper "$metadata" "$current_index" "${line_words[@]}" 2>/dev/null)" || return 0 + [[ -z "$output" ]] && return 0 + + _tempest_collect_candidates "$output" "$segment_prefix" + _tempest_enable_nospace_for_assignment_candidates +} + +if [[ -z "${__tempest_php_original_completion:-}" ]] && complete -p php >/dev/null 2>&1; then + __tempest_php_original_completion="$(complete -p php 2>/dev/null)" + + if [[ "$__tempest_php_original_completion" == *"-F _tempest"* ]]; then + __tempest_php_original_completion='' + fi +fi + +complete -o bashdefault -o default -F _tempest tempest +complete -o bashdefault -o default -F _tempest ./tempest +complete -o bashdefault -o default -F _tempest ./vendor/bin/tempest +complete -o bashdefault -o default -F _tempest vendor/bin/tempest +complete -o bashdefault -o default -F _tempest php diff --git a/packages/console/src/completion.zsh b/packages/console/src/completion.zsh index 1017276ef..553b16cfa 100644 --- a/packages/console/src/completion.zsh +++ b/packages/console/src/completion.zsh @@ -1,54 +1,151 @@ -#compdef -p '*/tempest' -p 'tempest' php +if ! (( $+functions[compdef] )); then + autoload -Uz compinit 2>/dev/null + compinit -i 2>/dev/null +fi -# Tempest Framework Zsh Completion +if (( $+functions[compdef] )); then + if [[ -z "${_tempest_php_original_compdef:-}" ]] && [[ -n "${_comps[php]:-}" ]] && [[ "${_comps[php]}" != "_tempest" ]]; then + typeset -g _tempest_php_original_compdef="${_comps[php]}" + fi -_tempest() { - local current="${words[CURRENT]}" tempest_cmd shift_count output - local -a args with_suffix without_suffix + _tempest_php_passthrough() { + local service=php - # Detect invocation: "php tempest ..." vs "./tempest ..." - if [[ "${words[1]}" == "php" ]]; then - tempest_cmd="${words[1]} ${words[2]}" - shift_count=2 - else - tempest_cmd="${words[1]}" - shift_count=1 - fi + if [[ -n "${_tempest_php_original_compdef:-}" ]] && (( $+functions[$_tempest_php_original_compdef] )); then + "$_tempest_php_original_compdef" + return $? + fi - # Build completion request arguments - # Skip "php" from inputs but keep the tempest binary and args - local skip=$((shift_count - 1)) - args=("--current=$((CURRENT - shift_count))") - for word in "${words[@]:$skip}"; do - args+=("--input=$word") - done - - # Fetch completions from tempest - output=$(eval "$tempest_cmd _complete ${args[*]}" 2>/dev/null) || return 0 - [[ -z "$output" ]] && return 0 - - # Parse completions, separating by suffix behavior - for line in "${(@f)output}"; do - [[ -z "$line" ]] && continue - if [[ "$line" == *= ]]; then - without_suffix+=("$line") - else - with_suffix+=("${line//:/\\:}") + if (( $+functions[_default] )); then + _default + return $? fi - done - # Add completions: no trailing space for "=" options, use _describe for commands - (( ${#without_suffix} )) && compadd -Q -S '' -- "${without_suffix[@]}" + return 1 + } + + _tempest_resolve_command() { + local command command_name passthrough_status - if (( ${#with_suffix} )); then - if [[ "$current" == -* || "${with_suffix[1]}" == -* ]]; then - compadd -Q -- "${with_suffix[@]}" + if [[ "${words[1]}" == "php" ]]; then + if (( ${#words[@]} < 2 )) || [[ -z "${words[2]}" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi + + command="${words[2]}" + command_name="${command:t}" + + if [[ "$command_name" != "tempest" ]]; then + _tempest_php_passthrough + passthrough_status=$? + REPLY="$passthrough_status" + return 3 + fi else - _describe -t commands 'tempest commands' with_suffix + command="${words[1]}" + command_name="${command:t}" fi - fi -} -compdef _tempest -p '*/tempest' -compdef _tempest -p 'tempest' -compdef _tempest php + [[ "$command_name" == "tempest" ]] || return 2 + + REPLY="$command" + return 0 + } + + _tempest_project_directory() { + local command="$1" + + if [[ "$command" == "vendor/bin/tempest" || "$command" == */vendor/bin/tempest ]]; then + REPLY="${command:h:h:A}" + return 0 + fi + + REPLY="${command:h:A}" + return 0 + } + + _tempest_add_completions() { + local output="$1" + local candidate completion_value completion_display tab + local -a completions with_equals_values with_equals_display without_equals_values without_equals_display + + completions=("${(@f)output}") + tab=$'\t' + + for candidate in "${completions[@]}"; do + [[ -z "$candidate" ]] && continue + + completion_value="${candidate%%$tab*}" + completion_display="$completion_value" + + # Helper entries can be "valuedescription". + if [[ "$candidate" == *"$tab"* ]]; then + completion_display="${candidate#*$tab}" + fi + + [[ -z "$completion_value" ]] && continue + + if [[ "$completion_value" == *= ]]; then + with_equals_values+=("$completion_value") + with_equals_display+=("$completion_display") + else + without_equals_values+=("$completion_value") + without_equals_display+=("$completion_display") + fi + done + + if (( ${#without_equals_values[@]} )); then + compadd -Q -l -d without_equals_display -- "${without_equals_values[@]}" + fi + + if (( ${#with_equals_values[@]} )); then + compadd -Q -l -d with_equals_display -S '' -- "${with_equals_values[@]}" + fi + } + + _tempest() { + local command project_directory helper metadata output resolve_status + + _tempest_resolve_command + resolve_status=$? + + case "$resolve_status" in + 0) + command="$REPLY" + ;; + 3) + return "$REPLY" + ;; + *) + return 0 + ;; + esac + + _compskip=all + + _tempest_project_directory "$command" + project_directory="$REPLY" + + helper="${project_directory}/.tempest/completion/tempest-complete" + metadata="${project_directory}/.tempest/completion/commands.json" + + [[ -x "$helper" ]] || return 0 + [[ -f "$metadata" ]] || return 0 + + output="$($helper "$metadata" "$((CURRENT - 1))" "${words[@]}" 2>/dev/null)" || return 0 + [[ -z "$output" ]] && return 0 + + _tempest_add_completions "$output" + return 0 + } + + compdef _tempest ./tempest + compdef _tempest vendor/bin/tempest + compdef _tempest ./vendor/bin/tempest + compdef _tempest php + compdef _tempest -p '*/tempest' + compdef _tempest -p '*/vendor/bin/tempest' +fi From ef128d92415f621aae39e0a9cb66706addcd59c9 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 12:01:09 +0100 Subject: [PATCH 05/30] refactor(console): update Shell enum for native completion --- packages/console/src/Enums/Shell.php | 30 ++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/console/src/Enums/Shell.php b/packages/console/src/Enums/Shell.php index ddf4ae28d..53b082c68 100644 --- a/packages/console/src/Enums/Shell.php +++ b/packages/console/src/Enums/Shell.php @@ -4,6 +4,8 @@ namespace Tempest\Console\Enums; +use Tempest\Console\CompletionRuntime; + enum Shell: string { case ZSH = 'zsh'; @@ -26,18 +28,13 @@ public static function detect(): ?self public function getCompletionsDirectory(): string { - $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; - - return match ($this) { - self::ZSH => $home . '/.zsh/completions', - self::BASH => $home . '/.bash_completion.d', - }; + return CompletionRuntime::getInstallationDirectory(); } public function getCompletionFilename(): string { return match ($this) { - self::ZSH => '_tempest', + self::ZSH => 'tempest.zsh', self::BASH => 'tempest.bash', }; } @@ -70,24 +67,19 @@ public function getRcFile(): string */ public function getPostInstallInstructions(): array { + $rcFile = $this->getRcFile(); + $installedPath = $this->getInstalledCompletionPath(); + return match ($this) { self::ZSH => [ - 'Add the completions directory to your fpath in ~/.zshrc:', - '', - ' fpath=(~/.zsh/completions $fpath)', - '', - 'Then reload completions:', + "Add this line to {$rcFile} and restart your terminal:", '', - ' autoload -Uz compinit && compinit', - '', - 'Or restart your terminal.', + " source {$installedPath}", ], self::BASH => [ - 'Source the completion file in your ~/.bashrc:', - '', - ' source ~/.bash_completion.d/tempest.bash', + "Add this line to {$rcFile} and restart your terminal:", '', - 'Or restart your terminal.', + " source {$installedPath}", ], }; } From edea7d03c890017bf9e172d2abdb0f08cf6d789c Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 12:01:16 +0100 Subject: [PATCH 06/30] feat(console): update completion commands for native helper --- .../Commands/CompletionGenerateCommand.php | 58 ++++++ .../src/Commands/CompletionInstallCommand.php | 48 +++-- .../src/Commands/CompletionShowCommand.php | 13 +- .../Commands/CompletionUninstallCommand.php | 46 ++--- .../CompletionGenerateCommandTest.php | 110 ++++++++++++ .../Commands/CompletionInstallCommandTest.php | 170 ++++++++++++++++-- .../Commands/CompletionShowCommandTest.php | 13 +- .../CompletionUninstallCommandTest.php | 33 +++- 8 files changed, 421 insertions(+), 70 deletions(-) create mode 100644 packages/console/src/Commands/CompletionGenerateCommand.php create mode 100644 tests/Integration/Console/Commands/CompletionGenerateCommandTest.php diff --git a/packages/console/src/Commands/CompletionGenerateCommand.php b/packages/console/src/Commands/CompletionGenerateCommand.php new file mode 100644 index 000000000..e1ff4deff --- /dev/null +++ b/packages/console/src/Commands/CompletionGenerateCommand.php @@ -0,0 +1,58 @@ +console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + + try { + ($this->ensureCompletionHelperBinary)(); + } catch (RuntimeException $runtimeException) { + $this->console->error($runtimeException->getMessage()); + + return ExitCode::ERROR; + } + + $path ??= CompletionRuntime::getMetadataPath(); + + Filesystem\write_json($path, ($this->buildCompletionMetadata)(), pretty: false); + + $this->console->success("Wrote completion metadata to: {$path}"); + + return ExitCode::SUCCESS; + } +} diff --git a/packages/console/src/Commands/CompletionInstallCommand.php b/packages/console/src/Commands/CompletionInstallCommand.php index 73e607c0a..2b159b1be 100644 --- a/packages/console/src/Commands/CompletionInstallCommand.php +++ b/packages/console/src/Commands/CompletionInstallCommand.php @@ -4,13 +4,19 @@ namespace Tempest\Console\Commands; +use RuntimeException; use Symfony\Component\Filesystem\Path; +use Tempest\Console\Actions\BuildCompletionMetadata; +use Tempest\Console\Actions\EnsureCompletionHelperBinary; +use Tempest\Console\Actions\RenderCompletionScript; use Tempest\Console\Actions\ResolveShell; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Console\Enums\Shell; use Tempest\Console\ExitCode; +use Tempest\Console\Middleware\ForceMiddleware; use Tempest\Support\Filesystem; use function Tempest\Support\path; @@ -20,11 +26,15 @@ public function __construct( private Console $console, private ResolveShell $resolveShell, + private BuildCompletionMetadata $buildCompletionMetadata, + private EnsureCompletionHelperBinary $ensureCompletionHelperBinary, + private RenderCompletionScript $renderCompletionScript, ) {} #[ConsoleCommand( name: 'completion:install', description: 'Install shell completion for Tempest', + middleware: [ForceMiddleware::class], )] public function __invoke( #[ConsoleArgument( @@ -32,12 +42,13 @@ public function __invoke( aliases: ['-s'], )] ?Shell $shell = null, - #[ConsoleArgument( - description: 'Skip confirmation prompts', - aliases: ['-f'], - )] - bool $force = false, ): ExitCode { + if (! CompletionRuntime::isSupportedPlatform()) { + $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + $shell ??= ($this->resolveShell)('Which shell do you want to install completions for?'); if ($shell === null) { @@ -56,7 +67,7 @@ public function __invoke( return ExitCode::ERROR; } - if (! $force) { + if (! $this->console->isForced) { $this->console->info("Installing {$shell->value} completions"); $this->console->keyValue('Source', $sourcePath); $this->console->keyValue('Target', $targetPath); @@ -69,17 +80,30 @@ public function __invoke( } } + Filesystem\write_json(CompletionRuntime::getMetadataPath(), ($this->buildCompletionMetadata)(), pretty: false); + + try { + ($this->ensureCompletionHelperBinary)(); + } catch (RuntimeException $runtimeException) { + $this->console->error($runtimeException->getMessage()); + + return ExitCode::ERROR; + } + Filesystem\ensure_directory_exists($targetDir); - if (Filesystem\is_file($targetPath)) { - if (! $force && ! $this->console->confirm('Completion file already exists. Overwrite?', default: false)) { - $this->console->warning('Installation cancelled.'); + if (Filesystem\is_file($targetPath) && ! $this->console->confirm('Completion file already exists. Overwrite?', default: true)) { + $this->console->warning('Installation cancelled.'); - return ExitCode::CANCELLED; - } + return ExitCode::CANCELLED; } - Filesystem\copy_file($sourcePath, $targetPath, overwrite: true); + $script = Filesystem\read_file($sourcePath); + Filesystem\write_file( + $targetPath, + ($this->renderCompletionScript)($script), + ); + $this->console->success("Installed completion script to: {$targetPath}"); $this->console->writeln(); diff --git a/packages/console/src/Commands/CompletionShowCommand.php b/packages/console/src/Commands/CompletionShowCommand.php index 1afeb3221..9949b5b25 100644 --- a/packages/console/src/Commands/CompletionShowCommand.php +++ b/packages/console/src/Commands/CompletionShowCommand.php @@ -5,7 +5,9 @@ namespace Tempest\Console\Commands; use Symfony\Component\Filesystem\Path; +use Tempest\Console\Actions\RenderCompletionScript; use Tempest\Console\Actions\ResolveShell; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; @@ -20,6 +22,7 @@ public function __construct( private Console $console, private ResolveShell $resolveShell, + private RenderCompletionScript $renderCompletionScript, ) {} #[ConsoleCommand( @@ -33,6 +36,12 @@ public function __invoke( )] ?Shell $shell = null, ): ExitCode { + if (! CompletionRuntime::isSupportedPlatform()) { + $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + $shell ??= ($this->resolveShell)('Which shell completion script do you want to see?'); if ($shell === null) { @@ -49,7 +58,9 @@ public function __invoke( return ExitCode::ERROR; } - $this->console->writeRaw(Filesystem\read_file($sourcePath)); + $this->console->writeRaw( + ($this->renderCompletionScript)(Filesystem\read_file($sourcePath)), + ); return ExitCode::SUCCESS; } diff --git a/packages/console/src/Commands/CompletionUninstallCommand.php b/packages/console/src/Commands/CompletionUninstallCommand.php index 7e0a0e61c..a1cf366e2 100644 --- a/packages/console/src/Commands/CompletionUninstallCommand.php +++ b/packages/console/src/Commands/CompletionUninstallCommand.php @@ -5,11 +5,13 @@ namespace Tempest\Console\Commands; use Tempest\Console\Actions\ResolveShell; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Console\Enums\Shell; use Tempest\Console\ExitCode; +use Tempest\Console\Middleware\ForceMiddleware; use Tempest\Support\Filesystem; final readonly class CompletionUninstallCommand @@ -22,6 +24,7 @@ public function __construct( #[ConsoleCommand( name: 'completion:uninstall', description: 'Uninstall shell completion for Tempest', + middleware: [ForceMiddleware::class], )] public function __invoke( #[ConsoleArgument( @@ -29,12 +32,13 @@ public function __invoke( aliases: ['-s'], )] ?Shell $shell = null, - #[ConsoleArgument( - description: 'Skip confirmation prompts', - aliases: ['-f'], - )] - bool $force = false, ): ExitCode { + if (! CompletionRuntime::isSupportedPlatform()) { + $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + $shell ??= ($this->resolveShell)('Which shell completions do you want to uninstall?'); if ($shell === null) { @@ -52,7 +56,7 @@ public function __invoke( return ExitCode::SUCCESS; } - if (! $force) { + if (! $this->console->isForced) { $this->console->info("Uninstalling {$shell->value} completions"); $this->console->keyValue('File', $targetPath); $this->console->writeln(); @@ -67,40 +71,10 @@ public function __invoke( Filesystem\delete_file($targetPath); $this->console->success("Removed completion script: {$targetPath}"); - if ($shell === Shell::ZSH) { - $this->cleanupZshCache(); - } - $this->console->writeln(); $this->console->info('Remember to remove any related lines from your shell configuration:'); $this->console->keyValue('Config file', $shell->getRcFile()); return ExitCode::SUCCESS; } - - private function cleanupZshCache(): void - { - $home = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? null; - - if ($home === null) { - return; - } - - $cacheFiles = glob("{$home}/.zcompdump*") ?: []; - - foreach ($cacheFiles as $file) { - Filesystem\delete_file($file); - } - - if ($cacheFiles !== []) { - $this->console->info('Cleared zsh completion cache (~/.zcompdump*)'); - } - - $this->console->writeln(); - $this->console->info('Run this to clear completions in your current shell:'); - $this->console->writeln(); - $this->console->writeln(" unset '_patcomps[php]' '_patcomps[tempest]' '_patcomps[*/tempest]' 2>/dev/null"); - $this->console->writeln(); - $this->console->info('Or restart your shell: exec zsh'); - } } diff --git a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php new file mode 100644 index 000000000..03ef87062 --- /dev/null +++ b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php @@ -0,0 +1,110 @@ +generatedPath !== null && Filesystem\is_file($this->generatedPath)) { + Filesystem\delete_file($this->generatedPath); + $this->generatedPath = null; + } + + if ($this->helperBinary !== null && Filesystem\is_file($this->helperBinary)) { + Filesystem\delete_file($this->helperBinary); + $this->helperBinary = null; + } + + if ($this->bundledHelperBinaryCreated && $this->bundledHelperBinary !== null && Filesystem\is_file($this->bundledHelperBinary)) { + Filesystem\delete_file($this->bundledHelperBinary); + } + + $this->bundledHelperBinary = null; + $this->bundledHelperBinaryCreated = false; + + parent::tearDown(); + } + + #[Test] + public function generate_writes_completion_metadata_to_default_path(): void + { + $this->generatedPath = CompletionRuntime::getMetadataPath(); + + if (Filesystem\is_file($this->generatedPath)) { + Filesystem\delete_file($this->generatedPath); + } + + $this->console + ->call('completion:generate') + ->assertSee('Wrote completion metadata to:') + ->assertSee('commands.json') + ->assertSuccess(); + + $this->assertTrue(Filesystem\is_file($this->generatedPath)); + + $metadata = Filesystem\read_json($this->generatedPath); + $flags = array_column($metadata['commands']['completion:test']['flags'], 'flag'); + $installFlags = array_column($metadata['commands']['completion:install']['flags'], null, 'name'); + + $this->assertSame(['--flag', '--items=', '--value='], $flags); + $this->assertSame('Install shell completion for Tempest', $metadata['commands']['completion:install']['description']); + $this->assertSame(['-s'], $installFlags['shell']['aliases']); + $this->assertSame('The shell to install completions for (zsh, bash)', $installFlags['shell']['description']); + $this->assertSame(['bash', 'zsh'], $installFlags['shell']['value_options']); + } + + #[Test] + public function generate_writes_completion_metadata_to_custom_path(): void + { + $this->generatedPath = $this->internalStorage . '/completion/custom-commands.json'; + + $this->console + ->call("completion:generate --path={$this->generatedPath}") + ->assertSee('Wrote completion metadata to:') + ->assertSee('custom-commands.json') + ->assertSuccess(); + + $this->assertTrue(Filesystem\is_file($this->generatedPath)); + } + + #[Test] + public function generate_overwrites_runtime_helper_binary_when_hashes_do_not_match(): void + { + $this->generatedPath = CompletionRuntime::getMetadataPath(); + $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + $this->bundledHelperBinary = CompletionRuntime::getBundledHelperBinaryPath(); + + if (! Filesystem\is_file($this->bundledHelperBinary)) { + Filesystem\ensure_directory_exists(dirname($this->bundledHelperBinary)); + Filesystem\write_file($this->bundledHelperBinary, "#!/bin/sh\nexit 0\n"); + chmod($this->bundledHelperBinary, 0o755); + $this->bundledHelperBinaryCreated = true; + } + + Filesystem\ensure_directory_exists(dirname($this->helperBinary)); + Filesystem\write_file($this->helperBinary, "#!/bin/sh\necho stale\n"); + chmod($this->helperBinary, 0o755); + + $this->console + ->call('completion:generate') + ->assertSuccess(); + + $this->assertSame(Filesystem\read_file($this->bundledHelperBinary), Filesystem\read_file($this->helperBinary)); + } +} diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php index ac5d80ccf..fbf72cdd8 100644 --- a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\Console\Commands; use PHPUnit\Framework\Attributes\Test; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Enums\Shell; use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -16,6 +17,31 @@ final class CompletionInstallCommandTest extends FrameworkIntegrationTestCase { private ?string $installedFile = null; + private ?string $metadataFile = null; + + private ?string $helperBinary = null; + + private ?string $bundledHelperBinary = null; + + private bool $bundledHelperBinaryCreated = false; + + private string $profileDirectory; + + private ?string $originalHome = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalHome = getenv('HOME') ?: null; + $this->profileDirectory = $this->internalStorage . '/profile'; + + Filesystem\ensure_directory_exists($this->profileDirectory); + putenv("HOME={$this->profileDirectory}"); + $_ENV['HOME'] = $this->profileDirectory; + $_SERVER['HOME'] = $this->profileDirectory; + } + protected function tearDown(): void { if ($this->installedFile !== null && Filesystem\is_file($this->installedFile)) { @@ -23,19 +49,51 @@ protected function tearDown(): void $this->installedFile = null; } + if ($this->metadataFile !== null && Filesystem\is_file($this->metadataFile)) { + Filesystem\delete_file($this->metadataFile); + $this->metadataFile = null; + } + + if ($this->helperBinary !== null && Filesystem\is_file($this->helperBinary)) { + Filesystem\delete_file($this->helperBinary); + $this->helperBinary = null; + } + + if ($this->bundledHelperBinaryCreated && $this->bundledHelperBinary !== null && Filesystem\is_file($this->bundledHelperBinary)) { + Filesystem\delete_file($this->bundledHelperBinary); + } + + $this->bundledHelperBinary = null; + $this->bundledHelperBinaryCreated = false; + + if ($this->originalHome === null) { + putenv('HOME'); + unset($_ENV['HOME'], $_SERVER['HOME']); + } else { + putenv("HOME={$this->originalHome}"); + $_ENV['HOME'] = $this->originalHome; + $_SERVER['HOME'] = $this->originalHome; + } + parent::tearDown(); } #[Test] public function install_with_explicit_shell_flag(): void { + $this->prepareCompletionRuntime(); + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); $this->console ->call('completion:install --shell=zsh --force') ->assertSee('Installed completion script to:') - ->assertSee('_tempest') ->assertSuccess(); + + $installedScript = Filesystem\read_file($this->installedFile); + + $this->assertStringContainsString('/.tempest/completion/tempest-complete', $installedScript); + $this->assertStringContainsString('/.tempest/completion/commands.json', $installedScript); } #[Test] @@ -51,30 +109,36 @@ public function install_with_invalid_shell(): void #[Test] public function install_shows_post_install_instructions_for_zsh(): void { + $this->prepareCompletionRuntime(); + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); $this->console ->call('completion:install --shell=zsh --force') - ->assertSee('fpath=') - ->assertSee('compinit') + ->assertSee('source') + ->assertSee('.zshrc') ->assertSuccess(); } #[Test] public function install_shows_post_install_instructions_for_bash(): void { + $this->prepareCompletionRuntime(); + $this->installedFile = Shell::BASH->getInstalledCompletionPath(); $this->console ->call('completion:install --shell=bash --force') ->assertSee('source') - ->assertSee('tempest.bash') + ->assertSee('.bashrc') ->assertSuccess(); } #[Test] public function install_cancelled_when_user_denies_confirmation(): void { + $this->prepareCompletionRuntime(); + $this->console ->call('completion:install --shell=zsh') ->assertSee('Installing zsh completions') @@ -84,26 +148,32 @@ public function install_cancelled_when_user_denies_confirmation(): void } #[Test] - public function install_creates_directory_if_not_exists(): void + public function install_asks_for_overwrite_when_file_exists(): void { - $targetDir = Shell::ZSH->getCompletionsDirectory(); - $dirExisted = Filesystem\is_directory($targetDir); + $this->prepareCompletionRuntime(); - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $targetPath = Shell::ZSH->getInstalledCompletionPath(); + $targetDir = Shell::ZSH->getCompletionsDirectory(); - $result = $this->console - ->call('completion:install --shell=zsh --force'); + Filesystem\create_directory($targetDir); + Filesystem\write_file($targetPath, '# existing content'); - if (! $dirExisted) { - $result->assertSee('Created directory:'); - } + $this->installedFile = $targetPath; - $result->assertSuccess(); + $this->console + ->call('completion:install --shell=zsh') + ->confirm() + ->assertSee('Completion file already exists') + ->deny() + ->assertSee('Installation cancelled') + ->assertCancelled(); } #[Test] - public function install_asks_for_overwrite_when_file_exists(): void + public function install_overwrites_existing_file_when_user_accepts_overwrite_default(): void { + $this->prepareCompletionRuntime(); + $targetPath = Shell::ZSH->getInstalledCompletionPath(); $targetDir = Shell::ZSH->getCompletionsDirectory(); @@ -116,8 +186,72 @@ public function install_asks_for_overwrite_when_file_exists(): void ->call('completion:install --shell=zsh') ->confirm() ->assertSee('Completion file already exists') - ->deny() - ->assertSee('Installation cancelled') - ->assertCancelled(); + ->submit() + ->assertSee('Installed completion script to:') + ->assertSuccess(); + } + + #[Test] + public function install_copies_bundled_helper_binary_when_runtime_binary_is_missing(): void + { + $this->prepareCompletionRuntime(withRuntimeHelperBinary: false); + $this->prepareBundledHelperBinary(); + + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + + $this->console + ->call('completion:install --shell=zsh --force') + ->assertSuccess(); + + $this->assertTrue(Filesystem\is_file($this->helperBinary)); + $this->assertSame(Filesystem\read_file($this->bundledHelperBinary), Filesystem\read_file($this->helperBinary)); + } + + #[Test] + public function install_overwrites_runtime_helper_binary_when_hashes_do_not_match(): void + { + $this->prepareCompletionRuntime(); + $this->prepareBundledHelperBinary(); + + $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + Filesystem\write_file($this->helperBinary, "#!/bin/sh\necho stale\n"); + chmod($this->helperBinary, 0o755); + + $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + + $this->console + ->call('completion:install --shell=zsh --force') + ->assertSuccess(); + + $this->assertSame(Filesystem\read_file($this->bundledHelperBinary), Filesystem\read_file($this->helperBinary)); + } + + private function prepareCompletionRuntime(bool $withRuntimeHelperBinary = true): void + { + $directory = CompletionRuntime::getDirectory(); + + Filesystem\ensure_directory_exists($directory); + + $this->metadataFile = CompletionRuntime::getMetadataPath(); + Filesystem\write_json($this->metadataFile, ['version' => 1, 'commands' => []]); + + if ($withRuntimeHelperBinary) { + $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + Filesystem\write_file($this->helperBinary, "#!/bin/sh\nexit 0\n"); + chmod($this->helperBinary, 0o755); + } + } + + private function prepareBundledHelperBinary(): void + { + $this->bundledHelperBinary = CompletionRuntime::getBundledHelperBinaryPath(); + + if (! Filesystem\is_file($this->bundledHelperBinary)) { + Filesystem\ensure_directory_exists(dirname($this->bundledHelperBinary)); + Filesystem\write_file($this->bundledHelperBinary, "#!/bin/sh\nexit 0\n"); + chmod($this->bundledHelperBinary, 0o755); + $this->bundledHelperBinaryCreated = true; + } } } diff --git a/tests/Integration/Console/Commands/CompletionShowCommandTest.php b/tests/Integration/Console/Commands/CompletionShowCommandTest.php index d4c1b3251..5571e25f4 100644 --- a/tests/Integration/Console/Commands/CompletionShowCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionShowCommandTest.php @@ -17,7 +17,10 @@ public function show_zsh_completion_script(): void { $this->console ->call('completion:show --shell=zsh') - ->assertSee('_tempest') + ->assertSee('tempest-complete') + ->assertSee('commands.json') + ->assertSee('_tempest_php_original_compdef') + ->assertSee('_compskip=all') ->assertSuccess(); } @@ -26,7 +29,13 @@ public function show_bash_completion_script(): void { $this->console ->call('completion:show --shell=bash') - ->assertSee('_tempest') + ->assertSee('tempest-complete') + ->assertSee('commands.json') + ->assertSee('if [[ "$current_word" == *[=:]* ]] && [[ "$current_segment" != "$current_word" ]]; then') + ->assertSee('if [[ -z "$current_segment" || "$current_segment" == "=" ]]; then') + ->assertSee('__tempest_php_original_completion') + ->assertSee('complete -o bashdefault -o default -F _tempest tempest') + ->assertSee('compopt +o default +o bashdefault') ->assertSuccess(); } diff --git a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php index 4f042f458..60bec3f07 100644 --- a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php @@ -14,6 +14,37 @@ */ final class CompletionUninstallCommandTest extends FrameworkIntegrationTestCase { + private string $profileDirectory; + + private ?string $originalHome = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalHome = getenv('HOME') ?: null; + $this->profileDirectory = $this->internalStorage . '/profile'; + + Filesystem\ensure_directory_exists($this->profileDirectory); + putenv("HOME={$this->profileDirectory}"); + $_ENV['HOME'] = $this->profileDirectory; + $_SERVER['HOME'] = $this->profileDirectory; + } + + protected function tearDown(): void + { + if ($this->originalHome === null) { + putenv('HOME'); + unset($_ENV['HOME'], $_SERVER['HOME']); + } else { + putenv("HOME={$this->originalHome}"); + $_ENV['HOME'] = $this->originalHome; + $_SERVER['HOME'] = $this->originalHome; + } + + parent::tearDown(); + } + #[Test] public function uninstall_with_explicit_shell_flag(): void { @@ -26,7 +57,7 @@ public function uninstall_with_explicit_shell_flag(): void $this->console ->call('completion:uninstall --shell=zsh --force') ->assertSee('Removed completion script:') - ->assertSee('_tempest') + ->assertSee('tempest.zsh') ->assertSuccess(); $this->assertFalse(Filesystem\is_file($targetPath)); From 1554e02202f5af5c906fd05db17ff92c50e64a2a Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:34:21 +0100 Subject: [PATCH 07/30] feat(console): add Rust completion helper crate and build script --- packages/console/.gitignore | 4 +- packages/console/completion-helper/Cargo.lock | 107 +++++ packages/console/completion-helper/Cargo.toml | 8 + .../console/completion-helper/build-binaries | 116 +++++ .../console/completion-helper/src/main.rs | 395 ++++++++++++++++++ 5 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 packages/console/completion-helper/Cargo.lock create mode 100644 packages/console/completion-helper/Cargo.toml create mode 100755 packages/console/completion-helper/build-binaries create mode 100644 packages/console/completion-helper/src/main.rs diff --git a/packages/console/.gitignore b/packages/console/.gitignore index 254f5fef4..f771ccda3 100644 --- a/packages/console/.gitignore +++ b/packages/console/.gitignore @@ -1,3 +1,5 @@ console.log debug.log -tempest.log \ No newline at end of file +tempest.log +completion-helper/target/ +tempest-complete_* diff --git a/packages/console/completion-helper/Cargo.lock b/packages/console/completion-helper/Cargo.lock new file mode 100644 index 000000000..1e7969bd5 --- /dev/null +++ b/packages/console/completion-helper/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempest-complete" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/console/completion-helper/Cargo.toml b/packages/console/completion-helper/Cargo.toml new file mode 100644 index 000000000..7d8d9945c --- /dev/null +++ b/packages/console/completion-helper/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "tempest-complete" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" diff --git a/packages/console/completion-helper/build-binaries b/packages/console/completion-helper/build-binaries new file mode 100755 index 000000000..8d5457b2c --- /dev/null +++ b/packages/console/completion-helper/build-binaries @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$root_dir" +package_dir="$(cd -- "$root_dir/.." && pwd)" + +default_targets=( + "x86_64-apple-darwin" + "aarch64-apple-darwin" + "x86_64-unknown-linux-musl" + "aarch64-unknown-linux-musl" +) + +if (($# == 0)); then + targets=("${default_targets[@]}") +else + targets=("$@") +fi + +platform_for_target() { + case "$1" in + x86_64-apple-darwin) echo "darwin_x86_64" ;; + aarch64-apple-darwin) echo "darwin_arm64" ;; + x86_64-unknown-linux-musl|x86_64-unknown-linux-gnu) echo "linux_x86_64" ;; + aarch64-unknown-linux-musl|aarch64-unknown-linux-gnu) echo "linux_arm64" ;; + *) + printf 'Unsupported target: %s\n' "$1" >&2 + exit 1 + ;; + esac +} + +binary_name_for_target() { + echo "tempest-complete" +} + +build_tool_for_target() { + local target="$1" + local preference="${TEMPEST_COMPLETION_BUILD_TOOL:-auto}" + + if [[ "$target" == *apple-darwin ]]; then + echo "cargo" + return + fi + + if [[ "$preference" == "zigbuild" ]]; then + echo "zigbuild" + return + fi + + if [[ "$preference" == "0" ]]; then + echo "cargo" + return + fi + + if [[ "$preference" == "cargo" ]]; then + echo "cargo" + return + fi + + if command -v cargo-zigbuild >/dev/null 2>&1; then + echo "zigbuild" + return + fi + + echo "cargo" +} + +for target in "${targets[@]}"; do + platform="$(platform_for_target "$target")" + binary_name="$(binary_name_for_target "$target")" + build_tool="$(build_tool_for_target "$target")" + + if [[ "$build_tool" == "zigbuild" ]]; then + if ! command -v cargo-zigbuild >/dev/null 2>&1; then + printf 'cargo-zigbuild is required for target %s but was not found. Install it with `cargo install cargo-zigbuild --locked`, or set TEMPEST_COMPLETION_BUILD_TOOL=cargo.\n' "$target" >&2 + exit 1 + fi + + rustup target add "$target" >/dev/null + else + rustup target add "$target" >/dev/null + fi + + printf 'Building %s with %s\n' "$target" "$build_tool" + + case "$build_tool" in + cargo) + cargo build --manifest-path "$root_dir/Cargo.toml" --release --locked --target "$target" + ;; + zigbuild) + cargo zigbuild --manifest-path "$root_dir/Cargo.toml" --release --locked --target "$target" + ;; + *) + printf 'Unsupported build tool: %s\n' "$build_tool" >&2 + exit 1 + ;; + esac + + source_path="$root_dir/target/$target/release/$binary_name" + + if [[ ! -f "$source_path" ]]; then + printf 'Expected binary not found at %s\n' "$source_path" >&2 + exit 1 + fi + + destination_dir="$package_dir/bin" + destination_path="$destination_dir/tempest-complete_$platform" + + mkdir -p "$destination_dir" + cp "$source_path" "$destination_path" + chmod 755 "$destination_path" + + printf 'Wrote %s\n' "$destination_path" +done diff --git a/packages/console/completion-helper/src/main.rs b/packages/console/completion-helper/src/main.rs new file mode 100644 index 000000000..4ef125b52 --- /dev/null +++ b/packages/console/completion-helper/src/main.rs @@ -0,0 +1,395 @@ +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::Path; + +use serde::Deserialize; + +#[derive(Deserialize)] +struct CompletionMetadata { + #[serde(default)] + commands: BTreeMap, +} + +#[derive(Deserialize)] +struct CommandMetadata { + #[serde(default)] + hidden: bool, + #[serde(default)] + description: Option, + #[serde(default)] + flags: Vec, +} + +#[derive(Deserialize)] +struct FlagMetadata { + name: String, + flag: String, + #[serde(default)] + aliases: Vec, + #[serde(default)] + description: Option, + #[serde(default)] + value_options: Vec, + repeatable: bool, +} + +struct Completion { + value: String, + display: Option, +} + +impl Completion { + fn plain(value: String) -> Self { + Self { + value, + display: None, + } + } + + fn with_display(value: String, display: String) -> Self { + Self { + value, + display: Some(display), + } + } + + fn into_output(self) -> String { + match self.display { + Some(display) => format!("{}\t{}", self.value, display), + None => self.value, + } + } +} + +fn main() { + let mut args = std::env::args().skip(1); + + let Some(metadata_path) = args.next() else { + return; + }; + + let Some(current_index) = args + .next() + .and_then(|current| current.parse::().ok()) + else { + return; + }; + + let words = args.collect::>(); + + let Some((normalized_words, normalized_index)) = normalize_words(words, current_index) else { + return; + }; + + let Ok(content) = fs::read_to_string(metadata_path) else { + return; + }; + + let Ok(metadata) = serde_json::from_str::(&content) else { + return; + }; + + let completions = complete(&metadata, &normalized_words, normalized_index); + + if completions.is_empty() { + return; + } + + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + for (i, item) in completions.into_iter().enumerate() { + if i > 0 { + let _ = out.write_all(b"\n"); + } + let _ = out.write_all(item.into_output().as_bytes()); + } +} + +fn normalize_words( + mut words: Vec, + mut current_index: usize, +) -> Option<(Vec, usize)> { + if words.is_empty() { + return None; + } + + if is_php_binary(&words[0]) { + if words.len() < 2 || !is_tempest_invocation(&words[1]) { + return None; + } + + words.remove(0); + current_index = current_index.saturating_sub(1); + } else if !is_tempest_invocation(&words[0]) { + return None; + } + + if words.is_empty() { + return None; + } + + if current_index >= words.len() { + words.push(String::new()); + } + + current_index = current_index.min(words.len().saturating_sub(1)); + + Some((words, current_index)) +} + +fn complete( + metadata: &CompletionMetadata, + words: &[String], + current_index: usize, +) -> Vec { + if words.is_empty() { + return Vec::new(); + } + + let current = words.get(current_index).map(|s| s.as_str()).unwrap_or(""); + + if current_index <= 1 { + return complete_commands(metadata, current); + } + + let command_name = &words[1]; + + if command_name.starts_with('-') { + return complete_commands(metadata, current); + } + + let Some(command) = metadata.commands.get(command_name.as_str()) else { + return Vec::new(); + }; + + complete_flags(command, words, current_index, current) +} + +fn complete_commands(metadata: &CompletionMetadata, current: &str) -> Vec { + if current.starts_with('-') { + return Vec::new(); + } + + let mut max_name_length = 0; + + let completions: Vec<(&str, Option)> = metadata + .commands + .iter() + .filter(|(_, command)| !command.hidden) + .filter(|(name, _)| name.starts_with(current)) + .map(|(name, command)| { + max_name_length = max_name_length.max(name.len()); + ( + name.as_str(), + sanitize_description(command.description.as_deref()), + ) + }) + .collect(); + + completions + .into_iter() + .map(|(name, description)| match description { + Some(description) => Completion::with_display( + name.to_owned(), + format!("{: Completion::plain(name.to_owned()), + }) + .collect() +} + +fn complete_flags( + command: &CommandMetadata, + words: &[String], + current_index: usize, + current: &str, +) -> Vec { + if !current.is_empty() && !current.starts_with('-') { + return Vec::new(); + } + + let used_flags = collect_used_flags(command, words, current_index); + + let mut max_label_length = 0; + + let completions: Vec<(String, String, Option)> = command + .flags + .iter() + .filter(|flag| flag.repeatable || !used_flags.contains(flag.name.as_str())) + .filter_map(|flag| { + let value = select_completion_value(flag, current)?; + let label = build_flag_label(flag); + let description = sanitize_description(flag.description.as_deref()); + max_label_length = max_label_length.max(label.len()); + Some((value, label, description)) + }) + .collect(); + + completions + .into_iter() + .map(|(value, label, description)| { + let display = match description { + Some(description) => { + format!( + "{: label, + }; + + Completion::with_display(value, display) + }) + .collect() +} + +fn select_completion_value(flag: &FlagMetadata, current: &str) -> Option { + let matches = |c: &&str| c.starts_with(current); + + let result = if current.starts_with("--") { + std::iter::once(flag.flag.as_str()) + .chain( + flag.aliases + .iter() + .map(String::as_str) + .filter(|alias| alias.starts_with("--")), + ) + .find(matches) + } else if current.starts_with('-') { + flag.aliases + .iter() + .map(String::as_str) + .chain(std::iter::once(flag.flag.as_str())) + .find(matches) + } else { + Some(flag.flag.as_str()).filter(matches) + }; + + result.map(ToOwned::to_owned) +} + +fn build_flag_label(flag: &FlagMetadata) -> String { + let mut label = flag.flag.clone(); + + if flag.flag.ends_with('=') && !flag.value_options.is_empty() { + label.push('<'); + label.push_str(&flag.value_options.join(",")); + label.push('>'); + } + + if !flag.aliases.is_empty() { + label.push_str(" / "); + label.push_str(&flag.aliases.join(" / ")); + } + + label +} + +fn sanitize_description(description: Option<&str>) -> Option { + description.and_then(|description| { + let mut words = description.split_whitespace(); + let first = words.next()?; + + let normalized = words.fold(first.to_owned(), |mut acc, word| { + acc.push(' '); + acc.push_str(word); + acc + }); + + Some(normalized) + }) +} + +fn collect_used_flags<'a>( + command: &'a CommandMetadata, + words: &[String], + current_index: usize, +) -> HashSet<&'a str> { + let mut used = HashSet::new(); + + for (index, word) in words.iter().enumerate().skip(2) { + if index == current_index { + continue; + } + + if word.starts_with("--") { + if let Some(name) = normalize_long_flag(word) + && let Some(flag_name) = resolve_flag_name(command, name) + { + used.insert(flag_name); + } + } else if word.starts_with('-') { + let short_value = word + .split_once('=') + .map(|(name, _)| name) + .unwrap_or(word) + .trim_start_matches('-'); + + if short_value.len() == 1 { + if let Some(flag_name) = resolve_flag_name(command, short_value) { + used.insert(flag_name); + } + } else { + for part in short_value.chars() { + let s = part.to_string(); + if let Some(flag_name) = resolve_flag_name(command, &s) { + used.insert(flag_name); + } + } + } + } + } + + used +} + +fn normalize_long_flag(value: &str) -> Option<&str> { + let mut normalized = value.trim_start_matches("--"); + + if let Some((name, _)) = normalized.split_once('=') { + normalized = name; + } + + if let Some(stripped) = normalized.strip_prefix("no-") { + normalized = stripped; + } + + if normalized.is_empty() { + return None; + } + + Some(normalized) +} + +fn resolve_flag_name<'a>(command: &'a CommandMetadata, value: &str) -> Option<&'a str> { + command + .flags + .iter() + .find(|flag| { + flag.name == value + || flag + .aliases + .iter() + .any(|alias| alias.trim_start_matches('-') == value) + }) + .map(|flag| flag.name.as_str()) +} + +fn is_php_binary(value: &str) -> bool { + basename(value) == "php" +} + +fn is_tempest_invocation(value: &str) -> bool { + basename(value) == "tempest" +} + +fn basename(value: &str) -> &str { + Path::new(value) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(value) +} From d7aafae735650faac966b344a4076d9ba3848aa3 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:34:30 +0100 Subject: [PATCH 08/30] ci: add workflow to build completion binaries on release --- .../workflows/build-completion-binaries.yml | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/build-completion-binaries.yml diff --git a/.github/workflows/build-completion-binaries.yml b/.github/workflows/build-completion-binaries.yml new file mode 100644 index 000000000..52dda5c4b --- /dev/null +++ b/.github/workflows/build-completion-binaries.yml @@ -0,0 +1,111 @@ +name: Build Completion Helper Binaries + +on: + release: + types: [published] + workflow_dispatch: + pull_request: + paths: + - "packages/console/completion-helper/**" + - ".github/workflows/build-completion-binaries.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-darwin: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26-large + target: x86_64-apple-darwin + bundle: tempest-complete_darwin_x86_64 + - runner: macos-26 + target: aarch64-apple-darwin + bundle: tempest-complete_darwin_arm64 + + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build completion helper binary + working-directory: packages/console/completion-helper + run: ./build-binaries "${{ matrix.target }}" + + - name: Upload binary + uses: actions/upload-artifact@v6 + with: + name: completion-helper-${{ matrix.bundle }} + path: packages/console/bin/${{ matrix.bundle }} + + build-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Zig + uses: mlugg/setup-zig@v2 + + - name: Install cargo-zigbuild + run: cargo install cargo-zigbuild --locked + + - name: Build completion helper binaries + working-directory: packages/console/completion-helper + env: + TEMPEST_COMPLETION_BUILD_TOOL: zigbuild + run: | + ./build-binaries \ + "x86_64-unknown-linux-musl" \ + "aarch64-unknown-linux-musl" + + - name: Upload Linux x86_64 binary + uses: actions/upload-artifact@v6 + with: + name: completion-helper-tempest-complete_linux_x86_64 + path: packages/console/bin/tempest-complete_linux_x86_64 + + - name: Upload Linux arm64 binary + uses: actions/upload-artifact@v6 + with: + name: completion-helper-tempest-complete_linux_arm64 + path: packages/console/bin/tempest-complete_linux_arm64 + + bundle: + runs-on: ubuntu-latest + permissions: + contents: write + needs: + - build-darwin + - build-linux + + steps: + - name: Download all binary artifacts + uses: actions/download-artifact@v7 + with: + pattern: completion-helper-* + merge-multiple: true + path: completion-helper-bin + + - name: Upload combined binaries artifact + uses: actions/upload-artifact@v6 + with: + name: completion-helper-binaries + path: completion-helper-bin/ + + - name: Upload binaries to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ github.event.release.tag_name }}" completion-helper-bin/* --clobber From 726070503362afbb0375f089b7a4cf49d8cd2e5e Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:34:40 +0100 Subject: [PATCH 09/30] refactor(console): download completion binary from GitHub releases --- .../Actions/EnsureCompletionHelperBinary.php | 60 ++++++++----------- .../Commands/CompletionGenerateCommand.php | 11 ---- packages/console/src/CompletionRuntime.php | 49 +++++++++++++-- .../console/tests/CompletionRuntimeTest.php | 12 ++-- .../CompletionGenerateCommandTest.php | 40 +++---------- .../Commands/CompletionInstallCommandTest.php | 59 ------------------ 6 files changed, 82 insertions(+), 149 deletions(-) diff --git a/packages/console/src/Actions/EnsureCompletionHelperBinary.php b/packages/console/src/Actions/EnsureCompletionHelperBinary.php index 60efa32b7..9aec3213b 100644 --- a/packages/console/src/Actions/EnsureCompletionHelperBinary.php +++ b/packages/console/src/Actions/EnsureCompletionHelperBinary.php @@ -8,39 +8,23 @@ use Tempest\Console\CompletionRuntime; use Tempest\Support\Filesystem; +use function Tempest\Support\box; + final readonly class EnsureCompletionHelperBinary { public function __invoke(): string { $binaryPath = CompletionRuntime::getHelperBinaryPath(); - $bundledBinaryPath = CompletionRuntime::getBundledHelperBinaryPath(); - - if (! Filesystem\is_file($bundledBinaryPath)) { - $platform = CompletionRuntime::getHelperBinaryPlatform(); - throw new RuntimeException("Completion helper binary for platform `{$platform}` was not found: {$bundledBinaryPath}"); + if (Filesystem\is_file($binaryPath) && Filesystem\is_executable($binaryPath)) { + return $binaryPath; } - $mustCopyBinary = true; - - if (Filesystem\is_file($binaryPath)) { - if (! Filesystem\is_executable($binaryPath)) { - chmod($binaryPath, 0o755); - } - - if ($this->hasMatchingHash($binaryPath, $bundledBinaryPath)) { - $mustCopyBinary = false; - } + $downloadUrl = CompletionRuntime::getHelperBinaryDownloadUrl(); + $binaryContents = $this->downloadBinary($downloadUrl); - if (! $mustCopyBinary && Filesystem\is_executable($binaryPath)) { - return $binaryPath; - } - } - - if ($mustCopyBinary) { - Filesystem\ensure_directory_exists(dirname($binaryPath)); - Filesystem\copy_file($bundledBinaryPath, $binaryPath, overwrite: true); - } + Filesystem\ensure_directory_exists(dirname($binaryPath)); + Filesystem\write_file($binaryPath, $binaryContents); chmod($binaryPath, 0o755); @@ -48,22 +32,28 @@ public function __invoke(): string return $binaryPath; } - throw new RuntimeException("Completion helper binary could not be made executable: {$binaryPath}"); + throw new RuntimeException("Downloaded completion helper binary could not be made executable: {$binaryPath}"); } - private function hasMatchingHash(string $runtimeBinaryPath, string $bundledBinaryPath): bool + private function downloadBinary(string $downloadUrl): string { - if (! Filesystem\is_readable($runtimeBinaryPath) || ! Filesystem\is_readable($bundledBinaryPath)) { - return false; - } - - $runtimeHash = hash_file('xxh128', $runtimeBinaryPath); - $bundledHash = hash_file('xxh128', $bundledBinaryPath); + $context = stream_context_create([ + 'http' => [ + 'follow_location' => 1, + 'max_redirects' => 10, + 'timeout' => 30, + 'user_agent' => 'tempest-completion-installer', + ], + ]); + + [$contents, $errorMessage] = box(static fn (): false|string => file_get_contents($downloadUrl, false, $context)); + + if (! is_string($contents) || $contents === '') { + $platform = CompletionRuntime::getHelperBinaryPlatform(); - if (! is_string($runtimeHash) || ! is_string($bundledHash)) { - return false; + throw new RuntimeException("Failed to download completion helper binary for platform `{$platform}` from {$downloadUrl}. {$errorMessage}"); } - return hash_equals($runtimeHash, $bundledHash); + return $contents; } } diff --git a/packages/console/src/Commands/CompletionGenerateCommand.php b/packages/console/src/Commands/CompletionGenerateCommand.php index e1ff4deff..da0004da4 100644 --- a/packages/console/src/Commands/CompletionGenerateCommand.php +++ b/packages/console/src/Commands/CompletionGenerateCommand.php @@ -4,9 +4,7 @@ namespace Tempest\Console\Commands; -use RuntimeException; use Tempest\Console\Actions\BuildCompletionMetadata; -use Tempest\Console\Actions\EnsureCompletionHelperBinary; use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; @@ -19,7 +17,6 @@ public function __construct( private Console $console, private BuildCompletionMetadata $buildCompletionMetadata, - private EnsureCompletionHelperBinary $ensureCompletionHelperBinary, ) {} #[ConsoleCommand( @@ -39,14 +36,6 @@ public function __invoke( return ExitCode::ERROR; } - try { - ($this->ensureCompletionHelperBinary)(); - } catch (RuntimeException $runtimeException) { - $this->console->error($runtimeException->getMessage()); - - return ExitCode::ERROR; - } - $path ??= CompletionRuntime::getMetadataPath(); Filesystem\write_json($path, ($this->buildCompletionMetadata)(), pretty: false); diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php index 5f4ebee00..e8ee8931d 100644 --- a/packages/console/src/CompletionRuntime.php +++ b/packages/console/src/CompletionRuntime.php @@ -14,6 +14,7 @@ final class CompletionRuntime { public const string HELPER_PATH_PLACEHOLDER = '__TEMPEST_COMPLETION_BINARY__'; public const string METADATA_PATH_PLACEHOLDER = '__TEMPEST_COMPLETION_METADATA__'; + public const string RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/tempestphp/tempest-framework/releases/download'; public static function getInstallationDirectory(): string { @@ -35,10 +36,32 @@ public static function getHelperBinaryPath(): string return internal_storage_path('completion', self::getHelperBinaryFilename()); } - public static function getBundledHelperBinaryPath(): string + public static function getHelperBinaryAssetFilename(): string { - return Path::canonicalize( - path(__DIR__, '..', 'bin', self::getBundledHelperBinaryFilename())->toString(), + return self::getHelperBinaryFilename() . '_' . str_replace('-', '_', self::getHelperBinaryPlatform()); + } + + public static function getHelperBinaryReleaseTag(): string + { + $releaseTag = self::resolveInstalledReleaseVersion(); + + if (str_contains($releaseTag, 'dev')) { + throw new RuntimeException("Completion helper binaries are only available for tagged releases. Current version is `{$releaseTag}`."); + } + + if (str_starts_with($releaseTag, 'v')) { + return $releaseTag; + } + + return "v{$releaseTag}"; + } + + public static function getHelperBinaryDownloadUrl(): string + { + return sprintf( + self::RELEASE_DOWNLOAD_BASE_URL . '/%s/%s', + self::getHelperBinaryReleaseTag(), + self::getHelperBinaryAssetFilename(), ); } @@ -77,9 +100,25 @@ public static function getHelperBinaryFilename(): string return 'tempest-complete'; } - public static function getBundledHelperBinaryFilename(): string + private static function resolveInstalledReleaseVersion(): string { - return self::getHelperBinaryFilename() . '_' . str_replace('-', '_', self::getHelperBinaryPlatform()); + if (! class_exists(\Composer\InstalledVersions::class)) { + throw new RuntimeException('Unable to determine the installed Tempest version to download completion helper binaries.'); + } + + foreach (['tempest/framework', 'tempest/console'] as $package) { + if (! \Composer\InstalledVersions::isInstalled($package)) { + continue; + } + + $version = \Composer\InstalledVersions::getPrettyVersion($package); + + if (is_string($version) && $version !== '') { + return $version; + } + } + + throw new RuntimeException('Unable to determine the installed Tempest version to download completion helper binaries.'); } private static function getProfileDirectory(): string diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index 4d9c08361..aa5c29d79 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -37,20 +37,20 @@ public function getUnsupportedPlatformMessage(): void } #[Test] - public function getBundledHelperBinaryFilename(): void + public function getHelperBinaryAssetFilename(): void { $this->assertMatchesRegularExpression( '/^tempest-complete_[a-z0-9]+_[a-z0-9_]+$/', - CompletionRuntime::getBundledHelperBinaryFilename(), + CompletionRuntime::getHelperBinaryAssetFilename(), ); } #[Test] - public function getBundledHelperBinaryPath(): void + public function getHelperBinaryReleaseTag_throws_for_dev_versions(): void { - $path = str_replace('\\', '/', CompletionRuntime::getBundledHelperBinaryPath()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('tagged releases'); - $this->assertStringContainsString('/packages/console/bin/', $path); - $this->assertStringEndsWith('/' . CompletionRuntime::getBundledHelperBinaryFilename(), $path); + CompletionRuntime::getHelperBinaryReleaseTag(); } } diff --git a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php index 03ef87062..28ed51106 100644 --- a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php @@ -13,12 +13,6 @@ final class CompletionGenerateCommandTest extends FrameworkIntegrationTestCase { private ?string $generatedPath = null; - private ?string $helperBinary = null; - - private ?string $bundledHelperBinary = null; - - private bool $bundledHelperBinaryCreated = false; - protected function tearDown(): void { if ($this->generatedPath !== null && Filesystem\is_file($this->generatedPath)) { @@ -26,18 +20,6 @@ protected function tearDown(): void $this->generatedPath = null; } - if ($this->helperBinary !== null && Filesystem\is_file($this->helperBinary)) { - Filesystem\delete_file($this->helperBinary); - $this->helperBinary = null; - } - - if ($this->bundledHelperBinaryCreated && $this->bundledHelperBinary !== null && Filesystem\is_file($this->bundledHelperBinary)) { - Filesystem\delete_file($this->bundledHelperBinary); - } - - $this->bundledHelperBinary = null; - $this->bundledHelperBinaryCreated = false; - parent::tearDown(); } @@ -64,6 +46,7 @@ public function generate_writes_completion_metadata_to_default_path(): void $this->assertSame(['--flag', '--items=', '--value='], $flags); $this->assertSame('Install shell completion for Tempest', $metadata['commands']['completion:install']['description']); + $this->assertSame('Update the completion helper binary', $metadata['commands']['completion:update-bin']['description']); $this->assertSame(['-s'], $installFlags['shell']['aliases']); $this->assertSame('The shell to install completions for (zsh, bash)', $installFlags['shell']['description']); $this->assertSame(['bash', 'zsh'], $installFlags['shell']['value_options']); @@ -84,27 +67,18 @@ public function generate_writes_completion_metadata_to_custom_path(): void } #[Test] - public function generate_overwrites_runtime_helper_binary_when_hashes_do_not_match(): void + public function generate_does_not_create_runtime_helper_binary(): void { - $this->generatedPath = CompletionRuntime::getMetadataPath(); - $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); - $this->bundledHelperBinary = CompletionRuntime::getBundledHelperBinaryPath(); - - if (! Filesystem\is_file($this->bundledHelperBinary)) { - Filesystem\ensure_directory_exists(dirname($this->bundledHelperBinary)); - Filesystem\write_file($this->bundledHelperBinary, "#!/bin/sh\nexit 0\n"); - chmod($this->bundledHelperBinary, 0o755); - $this->bundledHelperBinaryCreated = true; - } + $helperBinary = CompletionRuntime::getHelperBinaryPath(); - Filesystem\ensure_directory_exists(dirname($this->helperBinary)); - Filesystem\write_file($this->helperBinary, "#!/bin/sh\necho stale\n"); - chmod($this->helperBinary, 0o755); + if (Filesystem\is_file($helperBinary)) { + Filesystem\delete_file($helperBinary); + } $this->console ->call('completion:generate') ->assertSuccess(); - $this->assertSame(Filesystem\read_file($this->bundledHelperBinary), Filesystem\read_file($this->helperBinary)); + $this->assertFalse(Filesystem\is_file($helperBinary)); } } diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php index fbf72cdd8..6224df8a2 100644 --- a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -21,10 +21,6 @@ final class CompletionInstallCommandTest extends FrameworkIntegrationTestCase private ?string $helperBinary = null; - private ?string $bundledHelperBinary = null; - - private bool $bundledHelperBinaryCreated = false; - private string $profileDirectory; private ?string $originalHome = null; @@ -59,13 +55,6 @@ protected function tearDown(): void $this->helperBinary = null; } - if ($this->bundledHelperBinaryCreated && $this->bundledHelperBinary !== null && Filesystem\is_file($this->bundledHelperBinary)) { - Filesystem\delete_file($this->bundledHelperBinary); - } - - $this->bundledHelperBinary = null; - $this->bundledHelperBinaryCreated = false; - if ($this->originalHome === null) { putenv('HOME'); unset($_ENV['HOME'], $_SERVER['HOME']); @@ -191,42 +180,6 @@ public function install_overwrites_existing_file_when_user_accepts_overwrite_def ->assertSuccess(); } - #[Test] - public function install_copies_bundled_helper_binary_when_runtime_binary_is_missing(): void - { - $this->prepareCompletionRuntime(withRuntimeHelperBinary: false); - $this->prepareBundledHelperBinary(); - - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); - $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); - - $this->console - ->call('completion:install --shell=zsh --force') - ->assertSuccess(); - - $this->assertTrue(Filesystem\is_file($this->helperBinary)); - $this->assertSame(Filesystem\read_file($this->bundledHelperBinary), Filesystem\read_file($this->helperBinary)); - } - - #[Test] - public function install_overwrites_runtime_helper_binary_when_hashes_do_not_match(): void - { - $this->prepareCompletionRuntime(); - $this->prepareBundledHelperBinary(); - - $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); - Filesystem\write_file($this->helperBinary, "#!/bin/sh\necho stale\n"); - chmod($this->helperBinary, 0o755); - - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); - - $this->console - ->call('completion:install --shell=zsh --force') - ->assertSuccess(); - - $this->assertSame(Filesystem\read_file($this->bundledHelperBinary), Filesystem\read_file($this->helperBinary)); - } - private function prepareCompletionRuntime(bool $withRuntimeHelperBinary = true): void { $directory = CompletionRuntime::getDirectory(); @@ -242,16 +195,4 @@ private function prepareCompletionRuntime(bool $withRuntimeHelperBinary = true): chmod($this->helperBinary, 0o755); } } - - private function prepareBundledHelperBinary(): void - { - $this->bundledHelperBinary = CompletionRuntime::getBundledHelperBinaryPath(); - - if (! Filesystem\is_file($this->bundledHelperBinary)) { - Filesystem\ensure_directory_exists(dirname($this->bundledHelperBinary)); - Filesystem\write_file($this->bundledHelperBinary, "#!/bin/sh\nexit 0\n"); - chmod($this->bundledHelperBinary, 0o755); - $this->bundledHelperBinaryCreated = true; - } - } } From 15e19736d2984453840ef183ac67bf94df6d2b8f Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:34:49 +0100 Subject: [PATCH 10/30] feat(console): add completion:update-bin command --- .../Commands/CompletionUpdateBinCommand.php | 45 +++++++++++++++++++ .../CompletionUpdateBinCommandTest.php | 38 ++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/console/src/Commands/CompletionUpdateBinCommand.php create mode 100644 tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php diff --git a/packages/console/src/Commands/CompletionUpdateBinCommand.php b/packages/console/src/Commands/CompletionUpdateBinCommand.php new file mode 100644 index 000000000..17959f544 --- /dev/null +++ b/packages/console/src/Commands/CompletionUpdateBinCommand.php @@ -0,0 +1,45 @@ +console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + + return ExitCode::ERROR; + } + + try { + $binaryPath = ($this->ensureCompletionHelperBinary)(); + } catch (RuntimeException $runtimeException) { + $this->console->error($runtimeException->getMessage()); + + return ExitCode::ERROR; + } + + $this->console->success("Updated completion helper binary: {$binaryPath}"); + + return ExitCode::SUCCESS; + } +} diff --git a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php new file mode 100644 index 000000000..1066cd1b4 --- /dev/null +++ b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php @@ -0,0 +1,38 @@ +helperBinary !== null && Filesystem\is_file($this->helperBinary)) { + Filesystem\delete_file($this->helperBinary); + $this->helperBinary = null; + } + + parent::tearDown(); + } + + #[Test] + public function update_bin_fails_gracefully_on_dev_version(): void + { + $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + + $this->console + ->call('completion:update-bin') + ->assertError(); + } +} From f47a2def85fe0ded733707348c844126185d1092 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:34:57 +0100 Subject: [PATCH 11/30] docs(console): update shell completion documentation --- docs/1-essentials/04-console-commands.md | 48 +++++++++++++++++------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/1-essentials/04-console-commands.md b/docs/1-essentials/04-console-commands.md index be30b55d9..ecf391634 100644 --- a/docs/1-essentials/04-console-commands.md +++ b/docs/1-essentials/04-console-commands.md @@ -240,7 +240,9 @@ Interactive components are only supported on Mac and Linux. On Windows, Tempest ## Shell completion -Tempest provides shell completion for Zsh and Bash. This allows you to press `Tab` to autocomplete command names and options. +Tempest provides shell completion for Zsh and Bash on Linux and macOS. This allows you to press `Tab` to autocomplete command names and options. On Windows, use WSL. + +Completion relies on two things: a **completion script** sourced by your shell, and a **helper binary** that performs the actual matching. The helper binary is not bundled with Tempest. It is downloaded from the GitHub release matching your installed Tempest version. ### Installing completions @@ -250,28 +252,46 @@ Run the install command and follow the prompts: ./tempest completion:install ``` -The installer will detect your current shell, copy the completion script to the appropriate location, and provide instructions for enabling it. +This will: + +1. Detect your shell (or use `--shell=zsh` / `--shell=bash`). +2. Generate completion metadata (`commands.json`) for all registered commands. +3. Download the platform-specific helper binary from the matching Tempest release. +4. Install the completion script to the appropriate location. -For Zsh, you'll need to ensure the completions directory is in your `fpath` and reload completions: +After installation, add the following line to your shell configuration file and restart your terminal: -```zsh -# Add to ~/.zshrc -fpath=(~/.zsh/completions $fpath) -autoload -Uz compinit && compinit +```bash +# Zsh: add to ~/.zshrc +source ~/.tempest/completion/tempest.zsh + +# Bash: add to ~/.bashrc +source ~/.tempest/completion/tempest.bash ``` -For Bash, source the completion file in your `~/.bashrc`: +### Keeping completions up to date -```bash -source ~/.bash_completion.d/tempest.bash +After adding or removing commands, regenerate the metadata: + +```console +./tempest completion:generate ``` -### Additional commands +After updating Tempest to a new version, update the helper binary: + +```console +./tempest completion:update-bin +``` -You may also use these related commands: +### Available commands -- `completion:show` — Output the completion script to stdout (useful for custom installation) -- `completion:uninstall` — Remove the installed completion script +| Command | Description | +|-------------------------|--------------------------------------------------------------------------| +| `completion:install` | Install the completion script and download the helper binary. | +| `completion:generate` | Regenerate the completion metadata JSON. | +| `completion:update-bin` | Re-download the helper binary for the current Tempest version. | +| `completion:show` | Output the completion script to stdout (useful for custom installation). | +| `completion:uninstall` | Remove the installed completion script. | ## Middleware From 9816dd960b1c2add7e404dbcb0915e081e45ed31 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:50:38 +0100 Subject: [PATCH 12/30] refactor(console): drop macOS x86_64 completion support --- packages/console/completion-helper/build-binaries | 2 -- packages/console/src/CompletionRuntime.php | 12 ++++++++---- packages/console/tests/CompletionRuntimeTest.php | 13 ++++++++----- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/console/completion-helper/build-binaries b/packages/console/completion-helper/build-binaries index 8d5457b2c..3d81e3cf1 100755 --- a/packages/console/completion-helper/build-binaries +++ b/packages/console/completion-helper/build-binaries @@ -6,7 +6,6 @@ cd "$root_dir" package_dir="$(cd -- "$root_dir/.." && pwd)" default_targets=( - "x86_64-apple-darwin" "aarch64-apple-darwin" "x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl" @@ -20,7 +19,6 @@ fi platform_for_target() { case "$1" in - x86_64-apple-darwin) echo "darwin_x86_64" ;; aarch64-apple-darwin) echo "darwin_arm64" ;; x86_64-unknown-linux-musl|x86_64-unknown-linux-gnu) echo "linux_x86_64" ;; aarch64-unknown-linux-musl|aarch64-unknown-linux-gnu) echo "linux_arm64" ;; diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php index e8ee8931d..4df34a524 100644 --- a/packages/console/src/CompletionRuntime.php +++ b/packages/console/src/CompletionRuntime.php @@ -65,17 +65,21 @@ public static function getHelperBinaryDownloadUrl(): string ); } - public static function isSupportedPlatform(?string $osFamily = null): bool + public static function isSupportedPlatform(?string $osFamily = null, ?string $architecture = null): bool { - return match ($osFamily ?? PHP_OS_FAMILY) { - 'Darwin', 'Linux' => true, + $osFamily ??= PHP_OS_FAMILY; + $architecture ??= strtolower((string) php_uname('m')); + + return match ($osFamily) { + 'Darwin' => $architecture === 'arm64', + 'Linux' => true, default => false, }; } public static function getUnsupportedPlatformMessage(): string { - return 'Completion commands are supported on Linux and macOS. Use WSL if you are on Windows.'; + return 'Completion commands are supported on Linux and macOS (Apple Silicon). Use WSL if you are on Windows.'; } public static function getHelperBinaryPlatform(): string diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index aa5c29d79..d976dac7b 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -16,17 +16,20 @@ final class CompletionRuntimeTest extends TestCase { #[Test] #[DataProvider('supportedPlatformDataProvider')] - public function isSupportedPlatform(string $osFamily, bool $expected): void + public function isSupportedPlatform(string $osFamily, string $architecture, bool $expected): void { - $this->assertSame($expected, CompletionRuntime::isSupportedPlatform($osFamily)); + $this->assertSame($expected, CompletionRuntime::isSupportedPlatform($osFamily, $architecture)); } public static function supportedPlatformDataProvider(): array { return [ - 'linux' => ['Linux', true], - 'darwin' => ['Darwin', true], - 'windows' => ['Windows', false], + 'linux x86_64' => ['Linux', 'x86_64', true], + 'linux arm64' => ['Linux', 'arm64', true], + 'darwin arm64' => ['Darwin', 'arm64', true], + + 'darwin x86_64' => ['Darwin', 'x86_64', false], + 'windows' => ['Windows', 'x86_64', false], ]; } From ac5b72bbedcc3f721b377caea11b042b7dd8b053 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:50:41 +0100 Subject: [PATCH 13/30] ci: drop x86_64 macOS from completion binary builds --- .../workflows/build-completion-binaries.yml | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-completion-binaries.yml b/.github/workflows/build-completion-binaries.yml index 52dda5c4b..3689e2e99 100644 --- a/.github/workflows/build-completion-binaries.yml +++ b/.github/workflows/build-completion-binaries.yml @@ -15,18 +15,7 @@ concurrency: jobs: build-darwin: - strategy: - fail-fast: false - matrix: - include: - - runner: macos-26-large - target: x86_64-apple-darwin - bundle: tempest-complete_darwin_x86_64 - - runner: macos-26 - target: aarch64-apple-darwin - bundle: tempest-complete_darwin_arm64 - - runs-on: ${{ matrix.runner }} + runs-on: macos-26 steps: - name: Checkout @@ -37,13 +26,13 @@ jobs: - name: Build completion helper binary working-directory: packages/console/completion-helper - run: ./build-binaries "${{ matrix.target }}" + run: ./build-binaries "aarch64-apple-darwin" - name: Upload binary uses: actions/upload-artifact@v6 with: - name: completion-helper-${{ matrix.bundle }} - path: packages/console/bin/${{ matrix.bundle }} + name: completion-helper-tempest-complete_darwin_arm64 + path: packages/console/bin/tempest-complete_darwin_arm64 build-linux: runs-on: ubuntu-latest From 1847f8a39efd0c56fb45ffc22abad1ffc9e3d595 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:50:44 +0100 Subject: [PATCH 14/30] docs(console): note Apple Silicon requirement for macOS --- docs/1-essentials/04-console-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/1-essentials/04-console-commands.md b/docs/1-essentials/04-console-commands.md index ecf391634..7e5bd711f 100644 --- a/docs/1-essentials/04-console-commands.md +++ b/docs/1-essentials/04-console-commands.md @@ -240,7 +240,7 @@ Interactive components are only supported on Mac and Linux. On Windows, Tempest ## Shell completion -Tempest provides shell completion for Zsh and Bash on Linux and macOS. This allows you to press `Tab` to autocomplete command names and options. On Windows, use WSL. +Tempest provides shell completion for Zsh and Bash on Linux and macOS (Apple Silicon). This allows you to press `Tab` to autocomplete command names and options. On Windows, use WSL. Completion relies on two things: a **completion script** sourced by your shell, and a **helper binary** that performs the actual matching. The helper binary is not bundled with Tempest. It is downloaded from the GitHub release matching your installed Tempest version. From c618fcf18ba0f078a8e2f7e99164d44d8a2dbeee Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 14:59:57 +0100 Subject: [PATCH 15/30] fix(console): update ShellTest for unified completion directory --- packages/console/tests/Enums/ShellTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/console/tests/Enums/ShellTest.php b/packages/console/tests/Enums/ShellTest.php index dcf569e96..fd6dc372d 100644 --- a/packages/console/tests/Enums/ShellTest.php +++ b/packages/console/tests/Enums/ShellTest.php @@ -56,14 +56,14 @@ public function getCompletionsDirectory(): void { $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; - $this->assertSame($home . '/.zsh/completions', Shell::ZSH->getCompletionsDirectory()); - $this->assertSame($home . '/.bash_completion.d', Shell::BASH->getCompletionsDirectory()); + $this->assertSame($home . '/.tempest/completion', Shell::ZSH->getCompletionsDirectory()); + $this->assertSame($home . '/.tempest/completion', Shell::BASH->getCompletionsDirectory()); } #[Test] public function getCompletionFilename(): void { - $this->assertSame('_tempest', Shell::ZSH->getCompletionFilename()); + $this->assertSame('tempest.zsh', Shell::ZSH->getCompletionFilename()); $this->assertSame('tempest.bash', Shell::BASH->getCompletionFilename()); } @@ -72,8 +72,8 @@ public function getInstalledCompletionPath(): void { $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; - $this->assertSame($home . '/.zsh/completions/_tempest', Shell::ZSH->getInstalledCompletionPath()); - $this->assertSame($home . '/.bash_completion.d/tempest.bash', Shell::BASH->getInstalledCompletionPath()); + $this->assertSame($home . '/.tempest/completion/tempest.zsh', Shell::ZSH->getInstalledCompletionPath()); + $this->assertSame($home . '/.tempest/completion/tempest.bash', Shell::BASH->getInstalledCompletionPath()); } #[Test] @@ -98,11 +98,11 @@ public function getPostInstallInstructions(): void $zshInstructions = Shell::ZSH->getPostInstallInstructions(); $this->assertIsArray($zshInstructions); $this->assertNotEmpty($zshInstructions); - $this->assertStringContainsString('fpath', $zshInstructions[0]); + $this->assertStringContainsStringIgnoringCase('source', implode("\n", $zshInstructions)); $bashInstructions = Shell::BASH->getPostInstallInstructions(); $this->assertIsArray($bashInstructions); $this->assertNotEmpty($bashInstructions); - $this->assertStringContainsStringIgnoringCase('source', $bashInstructions[0]); + $this->assertStringContainsStringIgnoringCase('source', implode("\n", $bashInstructions)); } } From a49877fae482165dedd5e2a0caf74eb74e71ed67 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 15:10:43 +0100 Subject: [PATCH 16/30] style(console): fix formatting --- packages/console/tests/CompletionRuntimeTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index d976dac7b..0146dec3d 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -27,7 +27,6 @@ public static function supportedPlatformDataProvider(): array 'linux x86_64' => ['Linux', 'x86_64', true], 'linux arm64' => ['Linux', 'arm64', true], 'darwin arm64' => ['Darwin', 'arm64', true], - 'darwin x86_64' => ['Darwin', 'x86_64', false], 'windows' => ['Windows', 'x86_64', false], ]; From dd9018d05b949af5bbd6a8dd1df6119bcf701167 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 13 Feb 2026 16:23:26 +0100 Subject: [PATCH 17/30] refactor(console): skip tests on windows --- packages/console/tests/CompletionRuntimeTest.php | 9 +++++++++ packages/console/tests/Enums/ShellTest.php | 9 +++++++++ .../Console/Commands/CompletionGenerateCommandTest.php | 9 +++++++++ .../Console/Commands/CompletionInstallCommandTest.php | 4 ++++ .../Console/Commands/CompletionShowCommandTest.php | 9 +++++++++ .../Console/Commands/CompletionUninstallCommandTest.php | 4 ++++ .../Console/Commands/CompletionUpdateBinCommandTest.php | 9 +++++++++ 7 files changed, 53 insertions(+) diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index 0146dec3d..70ea467bf 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -14,6 +14,15 @@ */ final class CompletionRuntimeTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + #[Test] #[DataProvider('supportedPlatformDataProvider')] public function isSupportedPlatform(string $osFamily, string $architecture, bool $expected): void diff --git a/packages/console/tests/Enums/ShellTest.php b/packages/console/tests/Enums/ShellTest.php index fd6dc372d..295861610 100644 --- a/packages/console/tests/Enums/ShellTest.php +++ b/packages/console/tests/Enums/ShellTest.php @@ -14,6 +14,15 @@ */ final class ShellTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + #[Test] #[DataProvider('detectDataProvider')] public function detect(string|false $shellEnv, ?Shell $expected): void diff --git a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php index 28ed51106..b16f012a5 100644 --- a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php @@ -13,6 +13,15 @@ final class CompletionGenerateCommandTest extends FrameworkIntegrationTestCase { private ?string $generatedPath = null; + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + protected function tearDown(): void { if ($this->generatedPath !== null && Filesystem\is_file($this->generatedPath)) { diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php index 6224df8a2..9dec1bca9 100644 --- a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -29,6 +29,10 @@ protected function setUp(): void { parent::setUp(); + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + $this->originalHome = getenv('HOME') ?: null; $this->profileDirectory = $this->internalStorage . '/profile'; diff --git a/tests/Integration/Console/Commands/CompletionShowCommandTest.php b/tests/Integration/Console/Commands/CompletionShowCommandTest.php index 5571e25f4..2afc0aa38 100644 --- a/tests/Integration/Console/Commands/CompletionShowCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionShowCommandTest.php @@ -12,6 +12,15 @@ */ final class CompletionShowCommandTest extends FrameworkIntegrationTestCase { + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + #[Test] public function show_zsh_completion_script(): void { diff --git a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php index 60bec3f07..fa9387300 100644 --- a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php @@ -22,6 +22,10 @@ protected function setUp(): void { parent::setUp(); + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + $this->originalHome = getenv('HOME') ?: null; $this->profileDirectory = $this->internalStorage . '/profile'; diff --git a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php index 1066cd1b4..c6b94b161 100644 --- a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php @@ -16,6 +16,15 @@ final class CompletionUpdateBinCommandTest extends FrameworkIntegrationTestCase { private ?string $helperBinary = null; + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + } + protected function tearDown(): void { if ($this->helperBinary !== null && Filesystem\is_file($this->helperBinary)) { From 23fb7efd7a4aa2b2cb3d59887014e61c6cff69b7 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 10:06:33 +0100 Subject: [PATCH 18/30] refactor(console): drop static keywords --- .../src/Actions/BuildCompletionMetadata.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/console/src/Actions/BuildCompletionMetadata.php b/packages/console/src/Actions/BuildCompletionMetadata.php index ba7089001..201147273 100644 --- a/packages/console/src/Actions/BuildCompletionMetadata.php +++ b/packages/console/src/Actions/BuildCompletionMetadata.php @@ -22,12 +22,12 @@ public function __invoke(): array foreach ($this->consoleConfig->commands as $name => $command) { $flags = array_map( - static fn (ConsoleArgumentDefinition $definition): array => [ + fn (ConsoleArgumentDefinition $definition): array => [ 'name' => $definition->name, - 'flag' => self::buildFlagNotation($definition), - 'aliases' => self::buildFlagAliases($definition), + 'flag' => $this->buildFlagNotation($definition), + 'aliases' => $this->buildFlagAliases($definition), 'description' => $definition->description, - 'value_options' => self::buildValueOptions($definition), + 'value_options' => $this->buildValueOptions($definition), 'repeatable' => $definition->type === 'array' || $definition->isVariadic, 'requires_value' => $definition->type !== 'bool', ], @@ -51,7 +51,7 @@ public function __invoke(): array ]; } - private static function buildFlagNotation(ConsoleArgumentDefinition $definition): string + private function buildFlagNotation(ConsoleArgumentDefinition $definition): string { $flag = "--{$definition->name}"; @@ -62,7 +62,7 @@ private static function buildFlagNotation(ConsoleArgumentDefinition $definition) return $flag; } - private static function buildFlagAliases(ConsoleArgumentDefinition $definition): array + private function buildFlagAliases(ConsoleArgumentDefinition $definition): array { $aliases = array_values(array_filter(array_map(static function (string $alias): ?string { $normalized = ltrim(str($alias)->trim()->kebab()->toString(), '-'); @@ -82,7 +82,7 @@ private static function buildFlagAliases(ConsoleArgumentDefinition $definition): return $aliases; } - private static function buildValueOptions(ConsoleArgumentDefinition $definition): array + private function buildValueOptions(ConsoleArgumentDefinition $definition): array { if (! $definition->isBackedEnum()) { return []; From bb1bcac7bd5247d94834620249107b43b0286f0d Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 10:06:41 +0100 Subject: [PATCH 19/30] fix(console): force binary download in update-bin command --- packages/console/src/Actions/EnsureCompletionHelperBinary.php | 4 ++-- packages/console/src/Commands/CompletionUpdateBinCommand.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/console/src/Actions/EnsureCompletionHelperBinary.php b/packages/console/src/Actions/EnsureCompletionHelperBinary.php index 9aec3213b..ffc6ef689 100644 --- a/packages/console/src/Actions/EnsureCompletionHelperBinary.php +++ b/packages/console/src/Actions/EnsureCompletionHelperBinary.php @@ -12,11 +12,11 @@ final readonly class EnsureCompletionHelperBinary { - public function __invoke(): string + public function __invoke(bool $update = false): string { $binaryPath = CompletionRuntime::getHelperBinaryPath(); - if (Filesystem\is_file($binaryPath) && Filesystem\is_executable($binaryPath)) { + if (!$update && Filesystem\is_file($binaryPath) && Filesystem\is_executable($binaryPath)) { return $binaryPath; } diff --git a/packages/console/src/Commands/CompletionUpdateBinCommand.php b/packages/console/src/Commands/CompletionUpdateBinCommand.php index 17959f544..2d3a7808a 100644 --- a/packages/console/src/Commands/CompletionUpdateBinCommand.php +++ b/packages/console/src/Commands/CompletionUpdateBinCommand.php @@ -31,7 +31,7 @@ public function __invoke(): ExitCode } try { - $binaryPath = ($this->ensureCompletionHelperBinary)(); + $binaryPath = ($this->ensureCompletionHelperBinary)(update: true); } catch (RuntimeException $runtimeException) { $this->console->error($runtimeException->getMessage()); From c9569e6688fd5d311c34872312e25b96da416b26 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 10:34:29 +0100 Subject: [PATCH 20/30] refactor(console): make CompletionRuntime an injectable singleton --- .../Actions/EnsureCompletionHelperBinary.php | 12 ++-- .../src/Actions/RenderCompletionScript.php | 8 ++- .../Commands/CompletionGenerateCommand.php | 7 +- .../src/Commands/CompletionInstallCommand.php | 13 ++-- .../src/Commands/CompletionShowCommand.php | 5 +- .../Commands/CompletionUninstallCommand.php | 7 +- .../Commands/CompletionUpdateBinCommand.php | 5 +- packages/console/src/CompletionRuntime.php | 70 +++++++++++++------ packages/console/src/Enums/Shell.php | 34 --------- .../console/tests/CompletionRuntimeTest.php | 44 ++++++++++-- packages/console/tests/Enums/ShellTest.php | 32 --------- .../CompletionGenerateCommandTest.php | 8 ++- .../Commands/CompletionInstallCommandTest.php | 24 ++++--- .../CompletionUninstallCommandTest.php | 19 +++-- .../CompletionUpdateBinCommandTest.php | 6 +- 15 files changed, 162 insertions(+), 132 deletions(-) diff --git a/packages/console/src/Actions/EnsureCompletionHelperBinary.php b/packages/console/src/Actions/EnsureCompletionHelperBinary.php index ffc6ef689..7ed791a6c 100644 --- a/packages/console/src/Actions/EnsureCompletionHelperBinary.php +++ b/packages/console/src/Actions/EnsureCompletionHelperBinary.php @@ -12,15 +12,19 @@ final readonly class EnsureCompletionHelperBinary { + public function __construct( + private CompletionRuntime $completionRuntime, + ) {} + public function __invoke(bool $update = false): string { - $binaryPath = CompletionRuntime::getHelperBinaryPath(); + $binaryPath = $this->completionRuntime->getHelperBinaryPath(); - if (!$update && Filesystem\is_file($binaryPath) && Filesystem\is_executable($binaryPath)) { + if (! $update && Filesystem\is_file($binaryPath) && Filesystem\is_executable($binaryPath)) { return $binaryPath; } - $downloadUrl = CompletionRuntime::getHelperBinaryDownloadUrl(); + $downloadUrl = $this->completionRuntime->getHelperBinaryDownloadUrl(); $binaryContents = $this->downloadBinary($downloadUrl); Filesystem\ensure_directory_exists(dirname($binaryPath)); @@ -49,7 +53,7 @@ private function downloadBinary(string $downloadUrl): string [$contents, $errorMessage] = box(static fn (): false|string => file_get_contents($downloadUrl, false, $context)); if (! is_string($contents) || $contents === '') { - $platform = CompletionRuntime::getHelperBinaryPlatform(); + $platform = $this->completionRuntime->getHelperBinaryPlatform(); throw new RuntimeException("Failed to download completion helper binary for platform `{$platform}` from {$downloadUrl}. {$errorMessage}"); } diff --git a/packages/console/src/Actions/RenderCompletionScript.php b/packages/console/src/Actions/RenderCompletionScript.php index 88796dc12..97b66c91e 100644 --- a/packages/console/src/Actions/RenderCompletionScript.php +++ b/packages/console/src/Actions/RenderCompletionScript.php @@ -8,6 +8,10 @@ final readonly class RenderCompletionScript { + public function __construct( + private CompletionRuntime $completionRuntime, + ) {} + public function __invoke(string $script): string { return str_replace( @@ -16,8 +20,8 @@ public function __invoke(string $script): string CompletionRuntime::METADATA_PATH_PLACEHOLDER, ], [ - CompletionRuntime::getHelperBinaryPath(), - CompletionRuntime::getMetadataPath(), + $this->completionRuntime->getHelperBinaryPath(), + $this->completionRuntime->getMetadataPath(), ], $script, ); diff --git a/packages/console/src/Commands/CompletionGenerateCommand.php b/packages/console/src/Commands/CompletionGenerateCommand.php index da0004da4..d62bf8824 100644 --- a/packages/console/src/Commands/CompletionGenerateCommand.php +++ b/packages/console/src/Commands/CompletionGenerateCommand.php @@ -16,6 +16,7 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private BuildCompletionMetadata $buildCompletionMetadata, ) {} @@ -30,13 +31,13 @@ public function __invoke( )] ?string $path = null, ): ExitCode { - if (! CompletionRuntime::isSupportedPlatform()) { - $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); return ExitCode::ERROR; } - $path ??= CompletionRuntime::getMetadataPath(); + $path ??= $this->completionRuntime->getMetadataPath(); Filesystem\write_json($path, ($this->buildCompletionMetadata)(), pretty: false); diff --git a/packages/console/src/Commands/CompletionInstallCommand.php b/packages/console/src/Commands/CompletionInstallCommand.php index 2b159b1be..eee750a5d 100644 --- a/packages/console/src/Commands/CompletionInstallCommand.php +++ b/packages/console/src/Commands/CompletionInstallCommand.php @@ -25,6 +25,7 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, private BuildCompletionMetadata $buildCompletionMetadata, private EnsureCompletionHelperBinary $ensureCompletionHelperBinary, @@ -43,8 +44,8 @@ public function __invoke( )] ?Shell $shell = null, ): ExitCode { - if (! CompletionRuntime::isSupportedPlatform()) { - $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); return ExitCode::ERROR; } @@ -58,8 +59,8 @@ public function __invoke( } $sourcePath = $this->getSourcePath($shell); - $targetDir = $shell->getCompletionsDirectory(); - $targetPath = $shell->getInstalledCompletionPath(); + $targetDir = $this->completionRuntime->getInstallationDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath($shell); if (! Filesystem\is_file($sourcePath)) { $this->console->error("Completion script not found: {$sourcePath}"); @@ -80,7 +81,7 @@ public function __invoke( } } - Filesystem\write_json(CompletionRuntime::getMetadataPath(), ($this->buildCompletionMetadata)(), pretty: false); + Filesystem\write_json($this->completionRuntime->getMetadataPath(), ($this->buildCompletionMetadata)(), pretty: false); try { ($this->ensureCompletionHelperBinary)(); @@ -108,7 +109,7 @@ public function __invoke( $this->console->writeln(); $this->console->info('Next steps:'); - $this->console->instructions($shell->getPostInstallInstructions()); + $this->console->instructions($this->completionRuntime->getPostInstallInstructions($shell)); return ExitCode::SUCCESS; } diff --git a/packages/console/src/Commands/CompletionShowCommand.php b/packages/console/src/Commands/CompletionShowCommand.php index 9949b5b25..bf2f532fb 100644 --- a/packages/console/src/Commands/CompletionShowCommand.php +++ b/packages/console/src/Commands/CompletionShowCommand.php @@ -21,6 +21,7 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, private RenderCompletionScript $renderCompletionScript, ) {} @@ -36,8 +37,8 @@ public function __invoke( )] ?Shell $shell = null, ): ExitCode { - if (! CompletionRuntime::isSupportedPlatform()) { - $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); return ExitCode::ERROR; } diff --git a/packages/console/src/Commands/CompletionUninstallCommand.php b/packages/console/src/Commands/CompletionUninstallCommand.php index a1cf366e2..5c4f1fd22 100644 --- a/packages/console/src/Commands/CompletionUninstallCommand.php +++ b/packages/console/src/Commands/CompletionUninstallCommand.php @@ -18,6 +18,7 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, ) {} @@ -33,8 +34,8 @@ public function __invoke( )] ?Shell $shell = null, ): ExitCode { - if (! CompletionRuntime::isSupportedPlatform()) { - $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); return ExitCode::ERROR; } @@ -47,7 +48,7 @@ public function __invoke( return ExitCode::ERROR; } - $targetPath = $shell->getInstalledCompletionPath(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath($shell); if (! Filesystem\is_file($targetPath)) { $this->console->warning("Completion file not found: {$targetPath}"); diff --git a/packages/console/src/Commands/CompletionUpdateBinCommand.php b/packages/console/src/Commands/CompletionUpdateBinCommand.php index 2d3a7808a..d544ab426 100644 --- a/packages/console/src/Commands/CompletionUpdateBinCommand.php +++ b/packages/console/src/Commands/CompletionUpdateBinCommand.php @@ -15,6 +15,7 @@ { public function __construct( private Console $console, + private CompletionRuntime $completionRuntime, private EnsureCompletionHelperBinary $ensureCompletionHelperBinary, ) {} @@ -24,8 +25,8 @@ public function __construct( )] public function __invoke(): ExitCode { - if (! CompletionRuntime::isSupportedPlatform()) { - $this->console->error(CompletionRuntime::getUnsupportedPlatformMessage()); + if (! $this->completionRuntime->isSupportedPlatform()) { + $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); return ExitCode::ERROR; } diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php index 4df34a524..a087d900d 100644 --- a/packages/console/src/CompletionRuntime.php +++ b/packages/console/src/CompletionRuntime.php @@ -6,44 +6,47 @@ use RuntimeException; use Symfony\Component\Filesystem\Path; +use Tempest\Console\Enums\Shell; +use Tempest\Container\Singleton; use function Tempest\internal_storage_path; use function Tempest\Support\path; -final class CompletionRuntime +#[Singleton] +final readonly class CompletionRuntime { public const string HELPER_PATH_PLACEHOLDER = '__TEMPEST_COMPLETION_BINARY__'; public const string METADATA_PATH_PLACEHOLDER = '__TEMPEST_COMPLETION_METADATA__'; public const string RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/tempestphp/tempest-framework/releases/download'; - public static function getInstallationDirectory(): string + public function getInstallationDirectory(): string { - return Path::canonicalize(path(self::getProfileDirectory(), '.tempest', 'completion')->toString()); + return Path::canonicalize(path($this->getProfileDirectory(), '.tempest', 'completion')->toString()); } - public static function getDirectory(): string + public function getDirectory(): string { return internal_storage_path('completion'); } - public static function getMetadataPath(): string + public function getMetadataPath(): string { return internal_storage_path('completion', 'commands.json'); } - public static function getHelperBinaryPath(): string + public function getHelperBinaryPath(): string { - return internal_storage_path('completion', self::getHelperBinaryFilename()); + return internal_storage_path('completion', $this->getHelperBinaryFilename()); } - public static function getHelperBinaryAssetFilename(): string + public function getHelperBinaryAssetFilename(): string { - return self::getHelperBinaryFilename() . '_' . str_replace('-', '_', self::getHelperBinaryPlatform()); + return $this->getHelperBinaryFilename() . '_' . str_replace('-', '_', $this->getHelperBinaryPlatform()); } - public static function getHelperBinaryReleaseTag(): string + public function getHelperBinaryReleaseTag(): string { - $releaseTag = self::resolveInstalledReleaseVersion(); + $releaseTag = $this->resolveInstalledReleaseVersion(); if (str_contains($releaseTag, 'dev')) { throw new RuntimeException("Completion helper binaries are only available for tagged releases. Current version is `{$releaseTag}`."); @@ -56,16 +59,16 @@ public static function getHelperBinaryReleaseTag(): string return "v{$releaseTag}"; } - public static function getHelperBinaryDownloadUrl(): string + public function getHelperBinaryDownloadUrl(): string { return sprintf( self::RELEASE_DOWNLOAD_BASE_URL . '/%s/%s', - self::getHelperBinaryReleaseTag(), - self::getHelperBinaryAssetFilename(), + $this->getHelperBinaryReleaseTag(), + $this->getHelperBinaryAssetFilename(), ); } - public static function isSupportedPlatform(?string $osFamily = null, ?string $architecture = null): bool + public function isSupportedPlatform(?string $osFamily = null, ?string $architecture = null): bool { $osFamily ??= PHP_OS_FAMILY; $architecture ??= strtolower((string) php_uname('m')); @@ -77,12 +80,12 @@ public static function isSupportedPlatform(?string $osFamily = null, ?string $ar }; } - public static function getUnsupportedPlatformMessage(): string + public function getUnsupportedPlatformMessage(): string { return 'Completion commands are supported on Linux and macOS (Apple Silicon). Use WSL if you are on Windows.'; } - public static function getHelperBinaryPlatform(): string + public function getHelperBinaryPlatform(): string { $os = match (PHP_OS_FAMILY) { 'Darwin' => 'darwin', @@ -99,12 +102,39 @@ public static function getHelperBinaryPlatform(): string return "{$os}-{$architecture}"; } - public static function getHelperBinaryFilename(): string + public function getHelperBinaryFilename(): string { return 'tempest-complete'; } - private static function resolveInstalledReleaseVersion(): string + public function getInstalledCompletionPath(Shell $shell): string + { + return $this->getInstallationDirectory() . '/' . $shell->getCompletionFilename(); + } + + /** + * @return string[] + */ + public function getPostInstallInstructions(Shell $shell): array + { + $rcFile = $shell->getRcFile(); + $installedPath = $this->getInstalledCompletionPath($shell); + + return match ($shell) { + Shell::ZSH => [ + "Add this line to {$rcFile} and restart your terminal:", + '', + " source {$installedPath}", + ], + Shell::BASH => [ + "Add this line to {$rcFile} and restart your terminal:", + '', + " source {$installedPath}", + ], + }; + } + + private function resolveInstalledReleaseVersion(): string { if (! class_exists(\Composer\InstalledVersions::class)) { throw new RuntimeException('Unable to determine the installed Tempest version to download completion helper binaries.'); @@ -125,7 +155,7 @@ private static function resolveInstalledReleaseVersion(): string throw new RuntimeException('Unable to determine the installed Tempest version to download completion helper binaries.'); } - private static function getProfileDirectory(): string + private function getProfileDirectory(): string { $profileDirectory = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? getenv('HOME') ?: null; diff --git a/packages/console/src/Enums/Shell.php b/packages/console/src/Enums/Shell.php index 53b082c68..3d4a5e054 100644 --- a/packages/console/src/Enums/Shell.php +++ b/packages/console/src/Enums/Shell.php @@ -4,8 +4,6 @@ namespace Tempest\Console\Enums; -use Tempest\Console\CompletionRuntime; - enum Shell: string { case ZSH = 'zsh'; @@ -26,11 +24,6 @@ public static function detect(): ?self }; } - public function getCompletionsDirectory(): string - { - return CompletionRuntime::getInstallationDirectory(); - } - public function getCompletionFilename(): string { return match ($this) { @@ -39,11 +32,6 @@ public function getCompletionFilename(): string }; } - public function getInstalledCompletionPath(): string - { - return $this->getCompletionsDirectory() . '/' . $this->getCompletionFilename(); - } - public function getSourceFilename(): string { return match ($this) { @@ -61,26 +49,4 @@ public function getRcFile(): string self::BASH => $home . '/.bashrc', }; } - - /** - * @return string[] - */ - public function getPostInstallInstructions(): array - { - $rcFile = $this->getRcFile(); - $installedPath = $this->getInstalledCompletionPath(); - - return match ($this) { - self::ZSH => [ - "Add this line to {$rcFile} and restart your terminal:", - '', - " source {$installedPath}", - ], - self::BASH => [ - "Add this line to {$rcFile} and restart your terminal:", - '', - " source {$installedPath}", - ], - }; - } } diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index 70ea467bf..83de2897a 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -8,12 +8,15 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Tempest\Console\CompletionRuntime; +use Tempest\Console\Enums\Shell; /** * @internal */ final class CompletionRuntimeTest extends TestCase { + private CompletionRuntime $completionRuntime; + protected function setUp(): void { parent::setUp(); @@ -21,13 +24,15 @@ protected function setUp(): void if (PHP_OS_FAMILY === 'Windows') { $this->markTestSkipped('Shell completion is not supported on Windows.'); } + + $this->completionRuntime = new CompletionRuntime(); } #[Test] #[DataProvider('supportedPlatformDataProvider')] public function isSupportedPlatform(string $osFamily, string $architecture, bool $expected): void { - $this->assertSame($expected, CompletionRuntime::isSupportedPlatform($osFamily, $architecture)); + $this->assertSame($expected, $this->completionRuntime->isSupportedPlatform($osFamily, $architecture)); } public static function supportedPlatformDataProvider(): array @@ -44,7 +49,7 @@ public static function supportedPlatformDataProvider(): array #[Test] public function getUnsupportedPlatformMessage(): void { - $this->assertStringContainsString('Windows', CompletionRuntime::getUnsupportedPlatformMessage()); + $this->assertStringContainsString('Windows', $this->completionRuntime->getUnsupportedPlatformMessage()); } #[Test] @@ -52,7 +57,7 @@ public function getHelperBinaryAssetFilename(): void { $this->assertMatchesRegularExpression( '/^tempest-complete_[a-z0-9]+_[a-z0-9_]+$/', - CompletionRuntime::getHelperBinaryAssetFilename(), + $this->completionRuntime->getHelperBinaryAssetFilename(), ); } @@ -62,6 +67,37 @@ public function getHelperBinaryReleaseTag_throws_for_dev_versions(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('tagged releases'); - CompletionRuntime::getHelperBinaryReleaseTag(); + $this->completionRuntime->getHelperBinaryReleaseTag(); + } + + #[Test] + public function getInstallationDirectory(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.tempest/completion', $this->completionRuntime->getInstallationDirectory()); + } + + #[Test] + public function getInstalledCompletionPath(): void + { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; + + $this->assertSame($home . '/.tempest/completion/tempest.zsh', $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH)); + $this->assertSame($home . '/.tempest/completion/tempest.bash', $this->completionRuntime->getInstalledCompletionPath(Shell::BASH)); + } + + #[Test] + public function getPostInstallInstructions(): void + { + $zshInstructions = $this->completionRuntime->getPostInstallInstructions(Shell::ZSH); + $this->assertIsArray($zshInstructions); + $this->assertNotEmpty($zshInstructions); + $this->assertStringContainsStringIgnoringCase('source', implode("\n", $zshInstructions)); + + $bashInstructions = $this->completionRuntime->getPostInstallInstructions(Shell::BASH); + $this->assertIsArray($bashInstructions); + $this->assertNotEmpty($bashInstructions); + $this->assertStringContainsStringIgnoringCase('source', implode("\n", $bashInstructions)); } } diff --git a/packages/console/tests/Enums/ShellTest.php b/packages/console/tests/Enums/ShellTest.php index 295861610..b79965b14 100644 --- a/packages/console/tests/Enums/ShellTest.php +++ b/packages/console/tests/Enums/ShellTest.php @@ -60,15 +60,6 @@ public static function detectDataProvider(): array ]; } - #[Test] - public function getCompletionsDirectory(): void - { - $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; - - $this->assertSame($home . '/.tempest/completion', Shell::ZSH->getCompletionsDirectory()); - $this->assertSame($home . '/.tempest/completion', Shell::BASH->getCompletionsDirectory()); - } - #[Test] public function getCompletionFilename(): void { @@ -76,15 +67,6 @@ public function getCompletionFilename(): void $this->assertSame('tempest.bash', Shell::BASH->getCompletionFilename()); } - #[Test] - public function getInstalledCompletionPath(): void - { - $home = $_SERVER['HOME'] ?? getenv('HOME') ?: ''; - - $this->assertSame($home . '/.tempest/completion/tempest.zsh', Shell::ZSH->getInstalledCompletionPath()); - $this->assertSame($home . '/.tempest/completion/tempest.bash', Shell::BASH->getInstalledCompletionPath()); - } - #[Test] public function getSourceFilename(): void { @@ -100,18 +82,4 @@ public function getRcFile(): void $this->assertSame($home . '/.zshrc', Shell::ZSH->getRcFile()); $this->assertSame($home . '/.bashrc', Shell::BASH->getRcFile()); } - - #[Test] - public function getPostInstallInstructions(): void - { - $zshInstructions = Shell::ZSH->getPostInstallInstructions(); - $this->assertIsArray($zshInstructions); - $this->assertNotEmpty($zshInstructions); - $this->assertStringContainsStringIgnoringCase('source', implode("\n", $zshInstructions)); - - $bashInstructions = Shell::BASH->getPostInstallInstructions(); - $this->assertIsArray($bashInstructions); - $this->assertNotEmpty($bashInstructions); - $this->assertStringContainsStringIgnoringCase('source', implode("\n", $bashInstructions)); - } } diff --git a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php index b16f012a5..3129230b7 100644 --- a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php @@ -13,6 +13,8 @@ final class CompletionGenerateCommandTest extends FrameworkIntegrationTestCase { private ?string $generatedPath = null; + private CompletionRuntime $completionRuntime; + protected function setUp(): void { parent::setUp(); @@ -20,6 +22,8 @@ protected function setUp(): void if (PHP_OS_FAMILY === 'Windows') { $this->markTestSkipped('Shell completion is not supported on Windows.'); } + + $this->completionRuntime = new CompletionRuntime(); } protected function tearDown(): void @@ -35,7 +39,7 @@ protected function tearDown(): void #[Test] public function generate_writes_completion_metadata_to_default_path(): void { - $this->generatedPath = CompletionRuntime::getMetadataPath(); + $this->generatedPath = $this->completionRuntime->getMetadataPath(); if (Filesystem\is_file($this->generatedPath)) { Filesystem\delete_file($this->generatedPath); @@ -78,7 +82,7 @@ public function generate_writes_completion_metadata_to_custom_path(): void #[Test] public function generate_does_not_create_runtime_helper_binary(): void { - $helperBinary = CompletionRuntime::getHelperBinaryPath(); + $helperBinary = $this->completionRuntime->getHelperBinaryPath(); if (Filesystem\is_file($helperBinary)) { Filesystem\delete_file($helperBinary); diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php index 9dec1bca9..ae7578110 100644 --- a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -25,6 +25,8 @@ final class CompletionInstallCommandTest extends FrameworkIntegrationTestCase private ?string $originalHome = null; + private CompletionRuntime $completionRuntime; + protected function setUp(): void { parent::setUp(); @@ -33,6 +35,8 @@ protected function setUp(): void $this->markTestSkipped('Shell completion is not supported on Windows.'); } + $this->completionRuntime = new CompletionRuntime(); + $this->originalHome = getenv('HOME') ?: null; $this->profileDirectory = $this->internalStorage . '/profile'; @@ -76,7 +80,7 @@ public function install_with_explicit_shell_flag(): void { $this->prepareCompletionRuntime(); - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $this->installedFile = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); $this->console ->call('completion:install --shell=zsh --force') @@ -104,7 +108,7 @@ public function install_shows_post_install_instructions_for_zsh(): void { $this->prepareCompletionRuntime(); - $this->installedFile = Shell::ZSH->getInstalledCompletionPath(); + $this->installedFile = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); $this->console ->call('completion:install --shell=zsh --force') @@ -118,7 +122,7 @@ public function install_shows_post_install_instructions_for_bash(): void { $this->prepareCompletionRuntime(); - $this->installedFile = Shell::BASH->getInstalledCompletionPath(); + $this->installedFile = $this->completionRuntime->getInstalledCompletionPath(Shell::BASH); $this->console ->call('completion:install --shell=bash --force') @@ -145,8 +149,8 @@ public function install_asks_for_overwrite_when_file_exists(): void { $this->prepareCompletionRuntime(); - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# existing content'); @@ -167,8 +171,8 @@ public function install_overwrites_existing_file_when_user_accepts_overwrite_def { $this->prepareCompletionRuntime(); - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# existing content'); @@ -186,15 +190,15 @@ public function install_overwrites_existing_file_when_user_accepts_overwrite_def private function prepareCompletionRuntime(bool $withRuntimeHelperBinary = true): void { - $directory = CompletionRuntime::getDirectory(); + $directory = $this->completionRuntime->getDirectory(); Filesystem\ensure_directory_exists($directory); - $this->metadataFile = CompletionRuntime::getMetadataPath(); + $this->metadataFile = $this->completionRuntime->getMetadataPath(); Filesystem\write_json($this->metadataFile, ['version' => 1, 'commands' => []]); if ($withRuntimeHelperBinary) { - $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + $this->helperBinary = $this->completionRuntime->getHelperBinaryPath(); Filesystem\write_file($this->helperBinary, "#!/bin/sh\nexit 0\n"); chmod($this->helperBinary, 0o755); } diff --git a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php index fa9387300..7983d379b 100644 --- a/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionUninstallCommandTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\Console\Commands; use PHPUnit\Framework\Attributes\Test; +use Tempest\Console\CompletionRuntime; use Tempest\Console\Enums\Shell; use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -18,6 +19,8 @@ final class CompletionUninstallCommandTest extends FrameworkIntegrationTestCase private ?string $originalHome = null; + private CompletionRuntime $completionRuntime; + protected function setUp(): void { parent::setUp(); @@ -26,6 +29,8 @@ protected function setUp(): void $this->markTestSkipped('Shell completion is not supported on Windows.'); } + $this->completionRuntime = new CompletionRuntime(); + $this->originalHome = getenv('HOME') ?: null; $this->profileDirectory = $this->internalStorage . '/profile'; @@ -52,8 +57,8 @@ protected function tearDown(): void #[Test] public function uninstall_with_explicit_shell_flag(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# completion script'); @@ -80,7 +85,7 @@ public function uninstall_with_invalid_shell(): void #[Test] public function uninstall_when_file_not_exists(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); if (Filesystem\is_file($targetPath)) { Filesystem\delete_file($targetPath); @@ -97,8 +102,8 @@ public function uninstall_when_file_not_exists(): void #[Test] public function uninstall_shows_config_file_reminder(): void { - $targetPath = Shell::BASH->getInstalledCompletionPath(); - $targetDir = Shell::BASH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::BASH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# completion script'); @@ -113,8 +118,8 @@ public function uninstall_shows_config_file_reminder(): void #[Test] public function uninstall_cancelled_when_user_denies_confirmation(): void { - $targetPath = Shell::ZSH->getInstalledCompletionPath(); - $targetDir = Shell::ZSH->getCompletionsDirectory(); + $targetPath = $this->completionRuntime->getInstalledCompletionPath(Shell::ZSH); + $targetDir = $this->completionRuntime->getInstallationDirectory(); Filesystem\create_directory($targetDir); Filesystem\write_file($targetPath, '# completion script'); diff --git a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php index c6b94b161..4c6a74dec 100644 --- a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php @@ -16,6 +16,8 @@ final class CompletionUpdateBinCommandTest extends FrameworkIntegrationTestCase { private ?string $helperBinary = null; + private CompletionRuntime $completionRuntime; + protected function setUp(): void { parent::setUp(); @@ -23,6 +25,8 @@ protected function setUp(): void if (PHP_OS_FAMILY === 'Windows') { $this->markTestSkipped('Shell completion is not supported on Windows.'); } + + $this->completionRuntime = new CompletionRuntime(); } protected function tearDown(): void @@ -38,7 +42,7 @@ protected function tearDown(): void #[Test] public function update_bin_fails_gracefully_on_dev_version(): void { - $this->helperBinary = CompletionRuntime::getHelperBinaryPath(); + $this->helperBinary = $this->completionRuntime->getHelperBinaryPath(); $this->console ->call('completion:update-bin') From 9f59692a470763aa8c7f48fd076f6ae99fcd6a42 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:34:01 +0100 Subject: [PATCH 21/30] feat(console): add PHP completion helper --- composer.json | 3 +- packages/console/bin/tempest-complete | 30 +++ packages/console/composer.json | 3 +- .../src/Completion/CompletionApplication.php | 58 +++++ .../Completion/CompletionArgumentParser.php | 24 ++ .../src/Completion/CompletionArguments.php | 14 + .../src/Completion/CompletionCandidate.php | 13 + .../src/Completion/CompletionCommand.php | 20 ++ .../CompletionDescriptionSanitizer.php | 23 ++ .../src/Completion/CompletionEngine.php | 246 ++++++++++++++++++ .../console/src/Completion/CompletionFlag.php | 17 ++ .../src/Completion/CompletionInput.php | 13 + .../Completion/CompletionInputNormalizer.php | 56 ++++ .../CompletionInvocationInspector.php | 25 ++ .../src/Completion/CompletionMetadata.php | 19 ++ .../CompletionMetadataFileReader.php | 21 ++ .../Completion/CompletionMetadataParser.php | 207 +++++++++++++++ .../Completion/CompletionOutputFormatter.php | 25 ++ .../console/tests/CompletionHelperPhpTest.php | 234 +++++++++++++++++ 19 files changed, 1049 insertions(+), 2 deletions(-) create mode 100755 packages/console/bin/tempest-complete create mode 100644 packages/console/src/Completion/CompletionApplication.php create mode 100644 packages/console/src/Completion/CompletionArgumentParser.php create mode 100644 packages/console/src/Completion/CompletionArguments.php create mode 100644 packages/console/src/Completion/CompletionCandidate.php create mode 100644 packages/console/src/Completion/CompletionCommand.php create mode 100644 packages/console/src/Completion/CompletionDescriptionSanitizer.php create mode 100644 packages/console/src/Completion/CompletionEngine.php create mode 100644 packages/console/src/Completion/CompletionFlag.php create mode 100644 packages/console/src/Completion/CompletionInput.php create mode 100644 packages/console/src/Completion/CompletionInputNormalizer.php create mode 100644 packages/console/src/Completion/CompletionInvocationInspector.php create mode 100644 packages/console/src/Completion/CompletionMetadata.php create mode 100644 packages/console/src/Completion/CompletionMetadataFileReader.php create mode 100644 packages/console/src/Completion/CompletionMetadataParser.php create mode 100644 packages/console/src/Completion/CompletionOutputFormatter.php create mode 100644 packages/console/tests/CompletionHelperPhpTest.php diff --git a/composer.json b/composer.json index 75b6fa585..c1f5d5fc7 100644 --- a/composer.json +++ b/composer.json @@ -237,7 +237,8 @@ } }, "bin": [ - "packages/console/bin/tempest" + "packages/console/bin/tempest", + "packages/console/bin/tempest-complete" ], "config": { "allow-plugins": { diff --git a/packages/console/bin/tempest-complete b/packages/console/bin/tempest-complete new file mode 100755 index 000000000..a62dbb1bc --- /dev/null +++ b/packages/console/bin/tempest-complete @@ -0,0 +1,30 @@ +#!/usr/bin/env php +run(array_slice($_SERVER['argv'] ?? [], 1)); diff --git a/packages/console/composer.json b/packages/console/composer.json index 302e4c6fe..c1cd4013c 100644 --- a/packages/console/composer.json +++ b/packages/console/composer.json @@ -31,6 +31,7 @@ } }, "bin": [ - "bin/tempest" + "bin/tempest", + "bin/tempest-complete" ] } diff --git a/packages/console/src/Completion/CompletionApplication.php b/packages/console/src/Completion/CompletionApplication.php new file mode 100644 index 000000000..263f56fb6 --- /dev/null +++ b/packages/console/src/Completion/CompletionApplication.php @@ -0,0 +1,58 @@ +argumentParser->parse($args); + + if (! $parsedArguments instanceof CompletionArguments) { + return; + } + + $input = $this->inputNormalizer->normalize($parsedArguments->words, $parsedArguments->currentIndex); + + if (! $input instanceof CompletionInput) { + return; + } + + $content = $this->metadataFileReader->read($parsedArguments->metadataPath); + + if (! is_string($content)) { + return; + } + + $metadata = $this->metadataParser->parseJson($content); + + if (! $metadata instanceof CompletionMetadata) { + return; + } + + $completions = $this->engine->complete($metadata, $input); + + if ($completions === []) { + return; + } + + $output = $this->outputFormatter->format($completions); + + if ($output === '') { + return; + } + + fwrite(STDOUT, $output); + } +} diff --git a/packages/console/src/Completion/CompletionArgumentParser.php b/packages/console/src/Completion/CompletionArgumentParser.php new file mode 100644 index 000000000..3f268d5a4 --- /dev/null +++ b/packages/console/src/Completion/CompletionArgumentParser.php @@ -0,0 +1,24 @@ +flagNameLookup[$value] ?? null; + } +} diff --git a/packages/console/src/Completion/CompletionDescriptionSanitizer.php b/packages/console/src/Completion/CompletionDescriptionSanitizer.php new file mode 100644 index 000000000..46daa59f5 --- /dev/null +++ b/packages/console/src/Completion/CompletionDescriptionSanitizer.php @@ -0,0 +1,23 @@ +words === []) { + return []; + } + + $current = $input->words[$input->currentIndex] ?? ''; + + if ($input->currentIndex <= 1) { + return $this->completeCommands($metadata, $current); + } + + $commandName = $input->words[1] ?? ''; + + if (str_starts_with($commandName, '-')) { + return $this->completeCommands($metadata, $current); + } + + $command = $metadata->findCommand($commandName); + + if (! $command instanceof CompletionCommand) { + return []; + } + + return $this->completeFlags($command, $input->words, $input->currentIndex, $current); + } + + private function completeCommands(CompletionMetadata $metadata, string $current): array + { + if (str_starts_with($current, '-')) { + return []; + } + + $maxNameLength = 0; + $candidates = []; + + foreach ($metadata->commands as $name => $command) { + if (! is_string($name) || ! $command instanceof CompletionCommand) { + continue; + } + + if ($command->hidden) { + continue; + } + + if (! str_starts_with($name, $current)) { + continue; + } + + $maxNameLength = max($maxNameLength, strlen($name)); + $candidates[] = [$name, $this->descriptionSanitizer->sanitize($command->description)]; + } + + $completions = []; + + foreach ($candidates as [$name, $description]) { + $completions[] = new CompletionCandidate( + value: $name, + display: $description === null + ? null + : sprintf("%-{$maxNameLength}s %s", $name, $description), + ); + } + + return $completions; + } + + private function completeFlags(CompletionCommand $command, array $words, int $currentIndex, string $current): array + { + if ($current !== '' && ! str_starts_with($current, '-')) { + return []; + } + + $usedFlags = $this->collectUsedFlags($command, $words, $currentIndex); + $maxLabelLength = 0; + $candidates = []; + + foreach ($command->flags as $flag) { + if (! $flag instanceof CompletionFlag) { + continue; + } + + if (! $flag->repeatable && isset($usedFlags[$flag->name])) { + continue; + } + + $value = $this->selectCompletionValue($flag, $current); + + if ($value === null) { + continue; + } + + $label = $this->buildFlagLabel($flag); + $description = $this->descriptionSanitizer->sanitize($flag->description); + + $maxLabelLength = max($maxLabelLength, strlen($label)); + $candidates[] = [$value, $label, $description]; + } + + $completions = []; + + foreach ($candidates as [$value, $label, $description]) { + $completions[] = new CompletionCandidate( + value: $value, + display: $description === null + ? $label + : sprintf("%-{$maxLabelLength}s %s", $label, $description), + ); + } + + return $completions; + } + + private function selectCompletionValue(CompletionFlag $flag, string $current): ?string + { + $candidates = []; + + if (str_starts_with($current, '--')) { + $candidates[] = $flag->flag; + + foreach ($flag->aliases as $alias) { + if (str_starts_with($alias, '--')) { + $candidates[] = $alias; + } + } + } elseif (str_starts_with($current, '-')) { + foreach ($flag->aliases as $alias) { + $candidates[] = $alias; + } + + $candidates[] = $flag->flag; + } else { + $candidates[] = $flag->flag; + } + + return array_find($candidates, static fn ($candidate) => str_starts_with($candidate, $current)); + } + + private function buildFlagLabel(CompletionFlag $flag): string + { + $label = $flag->flag; + + if ($flag->valueOptions !== [] && str_ends_with($flag->flag, '=')) { + $label .= '<' . implode(',', $flag->valueOptions) . '>'; + } + + if ($flag->aliases !== []) { + $label .= ' / ' . implode(' / ', $flag->aliases); + } + + return $label; + } + + private function collectUsedFlags(CompletionCommand $command, array $words, int $currentIndex): array + { + $used = []; + + for ($index = 2, $max = count($words); $index < $max; $index++) { + if ($index === $currentIndex) { + continue; + } + + $word = $words[$index] ?? null; + + if (! is_string($word)) { + continue; + } + + if (str_starts_with($word, '--')) { + $name = $this->normalizeLongFlag($word); + + if ($name === null) { + continue; + } + + $flagName = $command->resolveFlagName($name); + + if ($flagName !== null) { + $used[$flagName] = true; + } + + continue; + } + + if (! str_starts_with($word, '-')) { + continue; + } + + $shortValue = explode('=', $word, 2)[0]; + $shortValue = ltrim($shortValue, '-'); + + if (strlen($shortValue) === 1) { + $flagName = $command->resolveFlagName($shortValue); + + if ($flagName !== null) { + $used[$flagName] = true; + } + + continue; + } + + foreach (str_split($shortValue) as $part) { + $flagName = $command->resolveFlagName($part); + + if ($flagName !== null) { + $used[$flagName] = true; + } + } + } + + return $used; + } + + private function normalizeLongFlag(string $value): ?string + { + $normalized = $value; + + while (str_starts_with($normalized, '--')) { + $normalized = substr($normalized, 2); + } + + $normalized = explode('=', $normalized, 2)[0]; + + if (str_starts_with($normalized, 'no-')) { + $normalized = substr($normalized, 3); + } + + if ($normalized === '') { + return null; + } + + return $normalized; + } +} diff --git a/packages/console/src/Completion/CompletionFlag.php b/packages/console/src/Completion/CompletionFlag.php new file mode 100644 index 000000000..28b0e4ffa --- /dev/null +++ b/packages/console/src/Completion/CompletionFlag.php @@ -0,0 +1,17 @@ +normalizeWords($words); + + if ($this->invocationInspector->isPhpBinary($normalizedWords[0])) { + if (count($normalizedWords) < 2 || ! $this->invocationInspector->isTempestInvocation($normalizedWords[1])) { + return null; + } + + array_shift($normalizedWords); + $currentIndex = max(0, $currentIndex - 1); + } + + if ($normalizedWords === [] || ! $this->invocationInspector->isTempestInvocation($normalizedWords[0])) { + return null; + } + + if ($currentIndex >= count($normalizedWords)) { + $normalizedWords[] = ''; + } + + $currentIndex = min($currentIndex, count($normalizedWords) - 1); + + return new CompletionInput( + words: $normalizedWords, + currentIndex: $currentIndex, + ); + } + + private function normalizeWords(array $words): array + { + $normalizedWords = []; + + foreach ($words as $word) { + $normalizedWords[] = is_string($word) ? $word : ''; + } + + return $normalizedWords; + } +} diff --git a/packages/console/src/Completion/CompletionInvocationInspector.php b/packages/console/src/Completion/CompletionInvocationInspector.php new file mode 100644 index 000000000..3f3fbd2b9 --- /dev/null +++ b/packages/console/src/Completion/CompletionInvocationInspector.php @@ -0,0 +1,25 @@ +basename($value) === 'php'; + } + + public function isTempestInvocation(string $value): bool + { + return $this->basename($value) === 'tempest'; + } + + private function basename(string $value): string + { + $basename = basename($value); + + return $basename === '' ? $value : $basename; + } +} diff --git a/packages/console/src/Completion/CompletionMetadata.php b/packages/console/src/Completion/CompletionMetadata.php new file mode 100644 index 000000000..bbebed2c5 --- /dev/null +++ b/packages/console/src/Completion/CompletionMetadata.php @@ -0,0 +1,19 @@ +commands[$name] ?? null; + + return $command instanceof CompletionCommand ? $command : null; + } +} diff --git a/packages/console/src/Completion/CompletionMetadataFileReader.php b/packages/console/src/Completion/CompletionMetadataFileReader.php new file mode 100644 index 000000000..ba09e8af7 --- /dev/null +++ b/packages/console/src/Completion/CompletionMetadataFileReader.php @@ -0,0 +1,21 @@ +parse($metadata); + } + + public function parse(mixed $metadata): ?CompletionMetadata + { + if (! is_array($metadata)) { + return null; + } + + $commandsMetadata = $metadata['commands'] ?? []; + + if (! is_array($commandsMetadata)) { + return null; + } + + $commands = []; + + foreach ($commandsMetadata as $name => $commandMetadata) { + if (! is_string($name) || ! is_array($commandMetadata)) { + return null; + } + + $command = $this->parseCommand($commandMetadata); + + if (! $command instanceof CompletionCommand) { + return null; + } + + $commands[$name] = $command; + } + + ksort($commands); + + return new CompletionMetadata($commands); + } + + private function parseCommand(array $command): ?CompletionCommand + { + $hidden = $command['hidden'] ?? false; + + if (! is_bool($hidden)) { + return null; + } + + $description = $command['description'] ?? null; + + if (! is_string($description) && $description !== null) { + return null; + } + + $flags = $this->parseFlags($command['flags'] ?? []); + + if (! is_array($flags)) { + return null; + } + + return new CompletionCommand( + hidden: $hidden, + description: $description, + flags: $flags, + flagNameLookup: $this->buildFlagNameLookup($flags), + ); + } + + private function parseFlags(mixed $flagsMetadata): ?array + { + if (! is_array($flagsMetadata)) { + return null; + } + + $flags = []; + + foreach ($flagsMetadata as $flagMetadata) { + if (! is_array($flagMetadata)) { + return null; + } + + $flag = $this->parseFlag($flagMetadata); + + if (! $flag instanceof CompletionFlag) { + return null; + } + + $flags[] = $flag; + } + + return $flags; + } + + private function parseFlag(array $flag): ?CompletionFlag + { + $name = $flag['name'] ?? null; + $notation = $flag['flag'] ?? null; + $repeatable = $flag['repeatable'] ?? null; + + if (! is_string($name) || ! is_string($notation) || ! is_bool($repeatable)) { + return null; + } + + $aliases = $this->parseStringList($flag['aliases'] ?? []); + + if (! is_array($aliases)) { + return null; + } + + $description = $flag['description'] ?? null; + + if (! is_string($description) && $description !== null) { + return null; + } + + $valueOptions = $this->parseStringList($flag['value_options'] ?? []); + + if (! is_array($valueOptions)) { + return null; + } + + return new CompletionFlag( + name: $name, + flag: $notation, + aliases: $aliases, + description: $description, + valueOptions: $valueOptions, + repeatable: $repeatable, + ); + } + + private function parseStringList(mixed $values): ?array + { + if (! is_array($values)) { + return null; + } + + $strings = []; + + foreach ($values as $value) { + if (! is_string($value)) { + return null; + } + + $strings[] = $value; + } + + return $strings; + } + + private function buildFlagNameLookup(array $flags): array + { + $lookup = []; + + foreach ($flags as $flag) { + $lookup[$flag->name] = $flag->name; + + $normalizedFlag = $this->normalizeFlagLookupValue($flag->flag); + + if ($normalizedFlag !== null) { + $lookup[$normalizedFlag] = $flag->name; + } + + foreach ($flag->aliases as $alias) { + $normalizedAlias = $this->normalizeFlagLookupValue($alias); + + if ($normalizedAlias !== null) { + $lookup[$normalizedAlias] = $flag->name; + } + } + } + + return $lookup; + } + + private function normalizeFlagLookupValue(string $value): ?string + { + $normalizedValue = ltrim($value, '-'); + $normalizedValue = rtrim($normalizedValue, '='); + + if (str_starts_with($normalizedValue, 'no-')) { + $normalizedValue = substr($normalizedValue, 3); + } + + if ($normalizedValue === '') { + return null; + } + + return $normalizedValue; + } +} diff --git a/packages/console/src/Completion/CompletionOutputFormatter.php b/packages/console/src/Completion/CompletionOutputFormatter.php new file mode 100644 index 000000000..faa7c1b7d --- /dev/null +++ b/packages/console/src/Completion/CompletionOutputFormatter.php @@ -0,0 +1,25 @@ +display === null + ? $completion->value + : "{$completion->value}\t{$completion->display}"; + } + + return implode("\n", $output); + } +} diff --git a/packages/console/tests/CompletionHelperPhpTest.php b/packages/console/tests/CompletionHelperPhpTest.php new file mode 100644 index 000000000..5398f9218 --- /dev/null +++ b/packages/console/tests/CompletionHelperPhpTest.php @@ -0,0 +1,234 @@ +engine = new CompletionEngine(); + $this->inputNormalizer = new CompletionInputNormalizer(); + $this->metadataParser = new CompletionMetadataParser(); + } + + #[Test] + public function entrypoint_is_executable_php_script(): void + { + $entrypoint = __DIR__ . '/../bin/tempest-complete'; + + $content = file_get_contents($entrypoint); + + $this->assertIsString($content); + $this->assertStringStartsWith("#!/usr/bin/env php\nparseMetadata([ + 'commands' => [ + 'about' => [ + 'hidden' => false, + 'description' => " About\n\tcommand ", + 'flags' => [], + ], + 'cache:clear' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [], + ], + 'hidden:command' => [ + 'hidden' => true, + 'description' => 'Should not be visible', + 'flags' => [], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', ''], 1); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + + $this->assertSame( + [ + ['value' => 'about', 'display' => 'about About command'], + ['value' => 'cache:clear', 'display' => null], + ], + $this->mapCompletions($completions), + ); + } + + #[Test] + public function skips_used_non_repeatable_flags_but_keeps_repeatable_flags(): void + { + $metadata = $this->parseMetadata([ + 'commands' => [ + 'deploy' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [ + [ + 'name' => 'force', + 'flag' => '--force', + 'aliases' => ['-f'], + 'description' => 'Force mode', + 'value_options' => [], + 'repeatable' => false, + ], + [ + 'name' => 'tag', + 'flag' => '--tag=', + 'aliases' => ['-t'], + 'description' => 'Tag value', + 'value_options' => ['alpha', 'beta'], + 'repeatable' => true, + ], + [ + 'name' => 'verbose', + 'flag' => '--verbose', + 'aliases' => ['-v'], + 'description' => 'Verbose output', + 'value_options' => [], + 'repeatable' => false, + ], + ], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', 'deploy', '--verbose', ''], 3); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + $mappedCompletions = $this->mapCompletions($completions); + + $this->assertSame(['--force', '--tag='], array_column($mappedCompletions, 'value')); + $this->assertStringContainsString('--tag= / -t', $mappedCompletions[1]['display']); + $this->assertStringContainsString('Tag value', $mappedCompletions[1]['display']); + } + + #[Test] + public function treats_combined_short_flags_as_used(): void + { + $metadata = $this->parseMetadata([ + 'commands' => [ + 'deploy' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [ + [ + 'name' => 'ansi', + 'flag' => '--ansi', + 'aliases' => ['-a'], + 'description' => null, + 'value_options' => [], + 'repeatable' => false, + ], + [ + 'name' => 'force', + 'flag' => '--force', + 'aliases' => ['-f'], + 'description' => null, + 'value_options' => [], + 'repeatable' => false, + ], + ], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', 'deploy', '-af', ''], 3); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + + $this->assertSame([], $this->mapCompletions($completions)); + } + + #[Test] + public function handles_php_passthrough_command_normalization(): void + { + $normalized = $this->inputNormalizer->normalize(['/usr/bin/php', 'vendor/bin/tempest', 'cache:clear'], 2); + + $this->assertInstanceOf(CompletionInput::class, $normalized); + + $this->assertSame( + ['vendor/bin/tempest', 'cache:clear'], + $normalized->words, + ); + + $this->assertSame(1, $normalized->currentIndex); + } + + #[Test] + public function ignores_no_prefixed_long_flags_when_marking_used_flags(): void + { + $metadata = $this->parseMetadata([ + 'commands' => [ + 'deploy' => [ + 'hidden' => false, + 'description' => null, + 'flags' => [ + [ + 'name' => 'ansi', + 'flag' => '--ansi', + 'aliases' => ['-a'], + 'description' => null, + 'value_options' => [], + 'repeatable' => false, + ], + ], + ], + ], + ]); + + $input = $this->inputNormalizer->normalize(['tempest', 'deploy', '--no-ansi', ''], 3); + + $this->assertInstanceOf(CompletionInput::class, $input); + + $completions = $this->engine->complete($metadata, $input); + + $this->assertSame([], $this->mapCompletions($completions)); + } + + private function parseMetadata(array $metadata): CompletionMetadata + { + $parsedMetadata = $this->metadataParser->parse($metadata); + + $this->assertInstanceOf(CompletionMetadata::class, $parsedMetadata); + + return $parsedMetadata; + } + + private function mapCompletions(array $completions): array + { + return array_map( + fn (CompletionCandidate $completion): array => [ + 'value' => $completion->value, + 'display' => $completion->display, + ], + $completions, + ); + } +} From 65fecbb1281ad3ddfd73eb6a908a779f1425f9a3 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:34:15 +0100 Subject: [PATCH 22/30] refactor(console): remove Rust completion helper --- .github/CODEOWNERS | 1 - .../workflows/build-completion-binaries.yml | 100 ----- packages/console/completion-helper/Cargo.lock | 107 ----- packages/console/completion-helper/Cargo.toml | 8 - .../console/completion-helper/build-binaries | 114 ----- .../console/completion-helper/src/main.rs | 395 ------------------ .../Actions/EnsureCompletionHelperBinary.php | 63 --- .../src/Actions/RenderCompletionScript.php | 29 -- .../Commands/CompletionUpdateBinCommand.php | 46 -- 9 files changed, 863 deletions(-) delete mode 100644 .github/workflows/build-completion-binaries.yml delete mode 100644 packages/console/completion-helper/Cargo.lock delete mode 100644 packages/console/completion-helper/Cargo.toml delete mode 100755 packages/console/completion-helper/build-binaries delete mode 100644 packages/console/completion-helper/src/main.rs delete mode 100644 packages/console/src/Actions/EnsureCompletionHelperBinary.php delete mode 100644 packages/console/src/Actions/RenderCompletionScript.php delete mode 100644 packages/console/src/Commands/CompletionUpdateBinCommand.php diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 081c9c2dc..7070e39c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,7 +18,6 @@ /packages/clock/ @aidan-casey /packages/command-bus/ @brendt @aidan-casey /packages/console/ @brendt -/packages/console/completion-helper/ @xHeaven /packages/container/ @brendt /packages/core/ @brendt /packages/cryptography/ @innocenzi diff --git a/.github/workflows/build-completion-binaries.yml b/.github/workflows/build-completion-binaries.yml deleted file mode 100644 index 3689e2e99..000000000 --- a/.github/workflows/build-completion-binaries.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Build Completion Helper Binaries - -on: - release: - types: [published] - workflow_dispatch: - pull_request: - paths: - - "packages/console/completion-helper/**" - - ".github/workflows/build-completion-binaries.yml" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-darwin: - runs-on: macos-26 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build completion helper binary - working-directory: packages/console/completion-helper - run: ./build-binaries "aarch64-apple-darwin" - - - name: Upload binary - uses: actions/upload-artifact@v6 - with: - name: completion-helper-tempest-complete_darwin_arm64 - path: packages/console/bin/tempest-complete_darwin_arm64 - - build-linux: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Setup Zig - uses: mlugg/setup-zig@v2 - - - name: Install cargo-zigbuild - run: cargo install cargo-zigbuild --locked - - - name: Build completion helper binaries - working-directory: packages/console/completion-helper - env: - TEMPEST_COMPLETION_BUILD_TOOL: zigbuild - run: | - ./build-binaries \ - "x86_64-unknown-linux-musl" \ - "aarch64-unknown-linux-musl" - - - name: Upload Linux x86_64 binary - uses: actions/upload-artifact@v6 - with: - name: completion-helper-tempest-complete_linux_x86_64 - path: packages/console/bin/tempest-complete_linux_x86_64 - - - name: Upload Linux arm64 binary - uses: actions/upload-artifact@v6 - with: - name: completion-helper-tempest-complete_linux_arm64 - path: packages/console/bin/tempest-complete_linux_arm64 - - bundle: - runs-on: ubuntu-latest - permissions: - contents: write - needs: - - build-darwin - - build-linux - - steps: - - name: Download all binary artifacts - uses: actions/download-artifact@v7 - with: - pattern: completion-helper-* - merge-multiple: true - path: completion-helper-bin - - - name: Upload combined binaries artifact - uses: actions/upload-artifact@v6 - with: - name: completion-helper-binaries - path: completion-helper-bin/ - - - name: Upload binaries to release - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "${{ github.event.release.tag_name }}" completion-helper-bin/* --clobber diff --git a/packages/console/completion-helper/Cargo.lock b/packages/console/completion-helper/Cargo.lock deleted file mode 100644 index 1e7969bd5..000000000 --- a/packages/console/completion-helper/Cargo.lock +++ /dev/null @@ -1,107 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "2.0.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempest-complete" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "unicode-ident" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/console/completion-helper/Cargo.toml b/packages/console/completion-helper/Cargo.toml deleted file mode 100644 index 7d8d9945c..000000000 --- a/packages/console/completion-helper/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "tempest-complete" -version = "0.1.0" -edition = "2024" - -[dependencies] -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" diff --git a/packages/console/completion-helper/build-binaries b/packages/console/completion-helper/build-binaries deleted file mode 100755 index 3d81e3cf1..000000000 --- a/packages/console/completion-helper/build-binaries +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -cd "$root_dir" -package_dir="$(cd -- "$root_dir/.." && pwd)" - -default_targets=( - "aarch64-apple-darwin" - "x86_64-unknown-linux-musl" - "aarch64-unknown-linux-musl" -) - -if (($# == 0)); then - targets=("${default_targets[@]}") -else - targets=("$@") -fi - -platform_for_target() { - case "$1" in - aarch64-apple-darwin) echo "darwin_arm64" ;; - x86_64-unknown-linux-musl|x86_64-unknown-linux-gnu) echo "linux_x86_64" ;; - aarch64-unknown-linux-musl|aarch64-unknown-linux-gnu) echo "linux_arm64" ;; - *) - printf 'Unsupported target: %s\n' "$1" >&2 - exit 1 - ;; - esac -} - -binary_name_for_target() { - echo "tempest-complete" -} - -build_tool_for_target() { - local target="$1" - local preference="${TEMPEST_COMPLETION_BUILD_TOOL:-auto}" - - if [[ "$target" == *apple-darwin ]]; then - echo "cargo" - return - fi - - if [[ "$preference" == "zigbuild" ]]; then - echo "zigbuild" - return - fi - - if [[ "$preference" == "0" ]]; then - echo "cargo" - return - fi - - if [[ "$preference" == "cargo" ]]; then - echo "cargo" - return - fi - - if command -v cargo-zigbuild >/dev/null 2>&1; then - echo "zigbuild" - return - fi - - echo "cargo" -} - -for target in "${targets[@]}"; do - platform="$(platform_for_target "$target")" - binary_name="$(binary_name_for_target "$target")" - build_tool="$(build_tool_for_target "$target")" - - if [[ "$build_tool" == "zigbuild" ]]; then - if ! command -v cargo-zigbuild >/dev/null 2>&1; then - printf 'cargo-zigbuild is required for target %s but was not found. Install it with `cargo install cargo-zigbuild --locked`, or set TEMPEST_COMPLETION_BUILD_TOOL=cargo.\n' "$target" >&2 - exit 1 - fi - - rustup target add "$target" >/dev/null - else - rustup target add "$target" >/dev/null - fi - - printf 'Building %s with %s\n' "$target" "$build_tool" - - case "$build_tool" in - cargo) - cargo build --manifest-path "$root_dir/Cargo.toml" --release --locked --target "$target" - ;; - zigbuild) - cargo zigbuild --manifest-path "$root_dir/Cargo.toml" --release --locked --target "$target" - ;; - *) - printf 'Unsupported build tool: %s\n' "$build_tool" >&2 - exit 1 - ;; - esac - - source_path="$root_dir/target/$target/release/$binary_name" - - if [[ ! -f "$source_path" ]]; then - printf 'Expected binary not found at %s\n' "$source_path" >&2 - exit 1 - fi - - destination_dir="$package_dir/bin" - destination_path="$destination_dir/tempest-complete_$platform" - - mkdir -p "$destination_dir" - cp "$source_path" "$destination_path" - chmod 755 "$destination_path" - - printf 'Wrote %s\n' "$destination_path" -done diff --git a/packages/console/completion-helper/src/main.rs b/packages/console/completion-helper/src/main.rs deleted file mode 100644 index 4ef125b52..000000000 --- a/packages/console/completion-helper/src/main.rs +++ /dev/null @@ -1,395 +0,0 @@ -use std::collections::{BTreeMap, HashSet}; -use std::fs; -use std::io::Write; -use std::path::Path; - -use serde::Deserialize; - -#[derive(Deserialize)] -struct CompletionMetadata { - #[serde(default)] - commands: BTreeMap, -} - -#[derive(Deserialize)] -struct CommandMetadata { - #[serde(default)] - hidden: bool, - #[serde(default)] - description: Option, - #[serde(default)] - flags: Vec, -} - -#[derive(Deserialize)] -struct FlagMetadata { - name: String, - flag: String, - #[serde(default)] - aliases: Vec, - #[serde(default)] - description: Option, - #[serde(default)] - value_options: Vec, - repeatable: bool, -} - -struct Completion { - value: String, - display: Option, -} - -impl Completion { - fn plain(value: String) -> Self { - Self { - value, - display: None, - } - } - - fn with_display(value: String, display: String) -> Self { - Self { - value, - display: Some(display), - } - } - - fn into_output(self) -> String { - match self.display { - Some(display) => format!("{}\t{}", self.value, display), - None => self.value, - } - } -} - -fn main() { - let mut args = std::env::args().skip(1); - - let Some(metadata_path) = args.next() else { - return; - }; - - let Some(current_index) = args - .next() - .and_then(|current| current.parse::().ok()) - else { - return; - }; - - let words = args.collect::>(); - - let Some((normalized_words, normalized_index)) = normalize_words(words, current_index) else { - return; - }; - - let Ok(content) = fs::read_to_string(metadata_path) else { - return; - }; - - let Ok(metadata) = serde_json::from_str::(&content) else { - return; - }; - - let completions = complete(&metadata, &normalized_words, normalized_index); - - if completions.is_empty() { - return; - } - - let stdout = std::io::stdout(); - let mut out = stdout.lock(); - - for (i, item) in completions.into_iter().enumerate() { - if i > 0 { - let _ = out.write_all(b"\n"); - } - let _ = out.write_all(item.into_output().as_bytes()); - } -} - -fn normalize_words( - mut words: Vec, - mut current_index: usize, -) -> Option<(Vec, usize)> { - if words.is_empty() { - return None; - } - - if is_php_binary(&words[0]) { - if words.len() < 2 || !is_tempest_invocation(&words[1]) { - return None; - } - - words.remove(0); - current_index = current_index.saturating_sub(1); - } else if !is_tempest_invocation(&words[0]) { - return None; - } - - if words.is_empty() { - return None; - } - - if current_index >= words.len() { - words.push(String::new()); - } - - current_index = current_index.min(words.len().saturating_sub(1)); - - Some((words, current_index)) -} - -fn complete( - metadata: &CompletionMetadata, - words: &[String], - current_index: usize, -) -> Vec { - if words.is_empty() { - return Vec::new(); - } - - let current = words.get(current_index).map(|s| s.as_str()).unwrap_or(""); - - if current_index <= 1 { - return complete_commands(metadata, current); - } - - let command_name = &words[1]; - - if command_name.starts_with('-') { - return complete_commands(metadata, current); - } - - let Some(command) = metadata.commands.get(command_name.as_str()) else { - return Vec::new(); - }; - - complete_flags(command, words, current_index, current) -} - -fn complete_commands(metadata: &CompletionMetadata, current: &str) -> Vec { - if current.starts_with('-') { - return Vec::new(); - } - - let mut max_name_length = 0; - - let completions: Vec<(&str, Option)> = metadata - .commands - .iter() - .filter(|(_, command)| !command.hidden) - .filter(|(name, _)| name.starts_with(current)) - .map(|(name, command)| { - max_name_length = max_name_length.max(name.len()); - ( - name.as_str(), - sanitize_description(command.description.as_deref()), - ) - }) - .collect(); - - completions - .into_iter() - .map(|(name, description)| match description { - Some(description) => Completion::with_display( - name.to_owned(), - format!("{: Completion::plain(name.to_owned()), - }) - .collect() -} - -fn complete_flags( - command: &CommandMetadata, - words: &[String], - current_index: usize, - current: &str, -) -> Vec { - if !current.is_empty() && !current.starts_with('-') { - return Vec::new(); - } - - let used_flags = collect_used_flags(command, words, current_index); - - let mut max_label_length = 0; - - let completions: Vec<(String, String, Option)> = command - .flags - .iter() - .filter(|flag| flag.repeatable || !used_flags.contains(flag.name.as_str())) - .filter_map(|flag| { - let value = select_completion_value(flag, current)?; - let label = build_flag_label(flag); - let description = sanitize_description(flag.description.as_deref()); - max_label_length = max_label_length.max(label.len()); - Some((value, label, description)) - }) - .collect(); - - completions - .into_iter() - .map(|(value, label, description)| { - let display = match description { - Some(description) => { - format!( - "{: label, - }; - - Completion::with_display(value, display) - }) - .collect() -} - -fn select_completion_value(flag: &FlagMetadata, current: &str) -> Option { - let matches = |c: &&str| c.starts_with(current); - - let result = if current.starts_with("--") { - std::iter::once(flag.flag.as_str()) - .chain( - flag.aliases - .iter() - .map(String::as_str) - .filter(|alias| alias.starts_with("--")), - ) - .find(matches) - } else if current.starts_with('-') { - flag.aliases - .iter() - .map(String::as_str) - .chain(std::iter::once(flag.flag.as_str())) - .find(matches) - } else { - Some(flag.flag.as_str()).filter(matches) - }; - - result.map(ToOwned::to_owned) -} - -fn build_flag_label(flag: &FlagMetadata) -> String { - let mut label = flag.flag.clone(); - - if flag.flag.ends_with('=') && !flag.value_options.is_empty() { - label.push('<'); - label.push_str(&flag.value_options.join(",")); - label.push('>'); - } - - if !flag.aliases.is_empty() { - label.push_str(" / "); - label.push_str(&flag.aliases.join(" / ")); - } - - label -} - -fn sanitize_description(description: Option<&str>) -> Option { - description.and_then(|description| { - let mut words = description.split_whitespace(); - let first = words.next()?; - - let normalized = words.fold(first.to_owned(), |mut acc, word| { - acc.push(' '); - acc.push_str(word); - acc - }); - - Some(normalized) - }) -} - -fn collect_used_flags<'a>( - command: &'a CommandMetadata, - words: &[String], - current_index: usize, -) -> HashSet<&'a str> { - let mut used = HashSet::new(); - - for (index, word) in words.iter().enumerate().skip(2) { - if index == current_index { - continue; - } - - if word.starts_with("--") { - if let Some(name) = normalize_long_flag(word) - && let Some(flag_name) = resolve_flag_name(command, name) - { - used.insert(flag_name); - } - } else if word.starts_with('-') { - let short_value = word - .split_once('=') - .map(|(name, _)| name) - .unwrap_or(word) - .trim_start_matches('-'); - - if short_value.len() == 1 { - if let Some(flag_name) = resolve_flag_name(command, short_value) { - used.insert(flag_name); - } - } else { - for part in short_value.chars() { - let s = part.to_string(); - if let Some(flag_name) = resolve_flag_name(command, &s) { - used.insert(flag_name); - } - } - } - } - } - - used -} - -fn normalize_long_flag(value: &str) -> Option<&str> { - let mut normalized = value.trim_start_matches("--"); - - if let Some((name, _)) = normalized.split_once('=') { - normalized = name; - } - - if let Some(stripped) = normalized.strip_prefix("no-") { - normalized = stripped; - } - - if normalized.is_empty() { - return None; - } - - Some(normalized) -} - -fn resolve_flag_name<'a>(command: &'a CommandMetadata, value: &str) -> Option<&'a str> { - command - .flags - .iter() - .find(|flag| { - flag.name == value - || flag - .aliases - .iter() - .any(|alias| alias.trim_start_matches('-') == value) - }) - .map(|flag| flag.name.as_str()) -} - -fn is_php_binary(value: &str) -> bool { - basename(value) == "php" -} - -fn is_tempest_invocation(value: &str) -> bool { - basename(value) == "tempest" -} - -fn basename(value: &str) -> &str { - Path::new(value) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(value) -} diff --git a/packages/console/src/Actions/EnsureCompletionHelperBinary.php b/packages/console/src/Actions/EnsureCompletionHelperBinary.php deleted file mode 100644 index 7ed791a6c..000000000 --- a/packages/console/src/Actions/EnsureCompletionHelperBinary.php +++ /dev/null @@ -1,63 +0,0 @@ -completionRuntime->getHelperBinaryPath(); - - if (! $update && Filesystem\is_file($binaryPath) && Filesystem\is_executable($binaryPath)) { - return $binaryPath; - } - - $downloadUrl = $this->completionRuntime->getHelperBinaryDownloadUrl(); - $binaryContents = $this->downloadBinary($downloadUrl); - - Filesystem\ensure_directory_exists(dirname($binaryPath)); - Filesystem\write_file($binaryPath, $binaryContents); - - chmod($binaryPath, 0o755); - - if (Filesystem\is_executable($binaryPath)) { - return $binaryPath; - } - - throw new RuntimeException("Downloaded completion helper binary could not be made executable: {$binaryPath}"); - } - - private function downloadBinary(string $downloadUrl): string - { - $context = stream_context_create([ - 'http' => [ - 'follow_location' => 1, - 'max_redirects' => 10, - 'timeout' => 30, - 'user_agent' => 'tempest-completion-installer', - ], - ]); - - [$contents, $errorMessage] = box(static fn (): false|string => file_get_contents($downloadUrl, false, $context)); - - if (! is_string($contents) || $contents === '') { - $platform = $this->completionRuntime->getHelperBinaryPlatform(); - - throw new RuntimeException("Failed to download completion helper binary for platform `{$platform}` from {$downloadUrl}. {$errorMessage}"); - } - - return $contents; - } -} diff --git a/packages/console/src/Actions/RenderCompletionScript.php b/packages/console/src/Actions/RenderCompletionScript.php deleted file mode 100644 index 97b66c91e..000000000 --- a/packages/console/src/Actions/RenderCompletionScript.php +++ /dev/null @@ -1,29 +0,0 @@ -completionRuntime->getHelperBinaryPath(), - $this->completionRuntime->getMetadataPath(), - ], - $script, - ); - } -} diff --git a/packages/console/src/Commands/CompletionUpdateBinCommand.php b/packages/console/src/Commands/CompletionUpdateBinCommand.php deleted file mode 100644 index d544ab426..000000000 --- a/packages/console/src/Commands/CompletionUpdateBinCommand.php +++ /dev/null @@ -1,46 +0,0 @@ -completionRuntime->isSupportedPlatform()) { - $this->console->error($this->completionRuntime->getUnsupportedPlatformMessage()); - - return ExitCode::ERROR; - } - - try { - $binaryPath = ($this->ensureCompletionHelperBinary)(update: true); - } catch (RuntimeException $runtimeException) { - $this->console->error($runtimeException->getMessage()); - - return ExitCode::ERROR; - } - - $this->console->success("Updated completion helper binary: {$binaryPath}"); - - return ExitCode::SUCCESS; - } -} From e44e06aa8a1d854109a642590a49a18e7a58ac51 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:34:31 +0100 Subject: [PATCH 23/30] refactor(console): simplify completion commands and runtime --- .../src/Commands/CompletionInstallCommand.php | 18 +--- .../src/Commands/CompletionShowCommand.php | 6 +- packages/console/src/CompletionRuntime.php | 94 +------------------ packages/console/src/completion.bash | 2 +- packages/console/src/completion.zsh | 2 +- 5 files changed, 7 insertions(+), 115 deletions(-) diff --git a/packages/console/src/Commands/CompletionInstallCommand.php b/packages/console/src/Commands/CompletionInstallCommand.php index eee750a5d..b70a7c0ce 100644 --- a/packages/console/src/Commands/CompletionInstallCommand.php +++ b/packages/console/src/Commands/CompletionInstallCommand.php @@ -4,11 +4,8 @@ namespace Tempest\Console\Commands; -use RuntimeException; use Symfony\Component\Filesystem\Path; use Tempest\Console\Actions\BuildCompletionMetadata; -use Tempest\Console\Actions\EnsureCompletionHelperBinary; -use Tempest\Console\Actions\RenderCompletionScript; use Tempest\Console\Actions\ResolveShell; use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; @@ -28,8 +25,6 @@ public function __construct( private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, private BuildCompletionMetadata $buildCompletionMetadata, - private EnsureCompletionHelperBinary $ensureCompletionHelperBinary, - private RenderCompletionScript $renderCompletionScript, ) {} #[ConsoleCommand( @@ -83,14 +78,6 @@ public function __invoke( Filesystem\write_json($this->completionRuntime->getMetadataPath(), ($this->buildCompletionMetadata)(), pretty: false); - try { - ($this->ensureCompletionHelperBinary)(); - } catch (RuntimeException $runtimeException) { - $this->console->error($runtimeException->getMessage()); - - return ExitCode::ERROR; - } - Filesystem\ensure_directory_exists($targetDir); if (Filesystem\is_file($targetPath) && ! $this->console->confirm('Completion file already exists. Overwrite?', default: true)) { @@ -100,10 +87,7 @@ public function __invoke( } $script = Filesystem\read_file($sourcePath); - Filesystem\write_file( - $targetPath, - ($this->renderCompletionScript)($script), - ); + Filesystem\write_file($targetPath, $script); $this->console->success("Installed completion script to: {$targetPath}"); diff --git a/packages/console/src/Commands/CompletionShowCommand.php b/packages/console/src/Commands/CompletionShowCommand.php index bf2f532fb..325e8b03a 100644 --- a/packages/console/src/Commands/CompletionShowCommand.php +++ b/packages/console/src/Commands/CompletionShowCommand.php @@ -5,7 +5,6 @@ namespace Tempest\Console\Commands; use Symfony\Component\Filesystem\Path; -use Tempest\Console\Actions\RenderCompletionScript; use Tempest\Console\Actions\ResolveShell; use Tempest\Console\CompletionRuntime; use Tempest\Console\Console; @@ -23,7 +22,6 @@ public function __construct( private Console $console, private CompletionRuntime $completionRuntime, private ResolveShell $resolveShell, - private RenderCompletionScript $renderCompletionScript, ) {} #[ConsoleCommand( @@ -59,9 +57,7 @@ public function __invoke( return ExitCode::ERROR; } - $this->console->writeRaw( - ($this->renderCompletionScript)(Filesystem\read_file($sourcePath)), - ); + $this->console->writeRaw(Filesystem\read_file($sourcePath)); return ExitCode::SUCCESS; } diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php index a087d900d..58ddc141d 100644 --- a/packages/console/src/CompletionRuntime.php +++ b/packages/console/src/CompletionRuntime.php @@ -15,96 +15,29 @@ #[Singleton] final readonly class CompletionRuntime { - public const string HELPER_PATH_PLACEHOLDER = '__TEMPEST_COMPLETION_BINARY__'; - public const string METADATA_PATH_PLACEHOLDER = '__TEMPEST_COMPLETION_METADATA__'; - public const string RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/tempestphp/tempest-framework/releases/download'; - public function getInstallationDirectory(): string { return Path::canonicalize(path($this->getProfileDirectory(), '.tempest', 'completion')->toString()); } - public function getDirectory(): string - { - return internal_storage_path('completion'); - } - public function getMetadataPath(): string { return internal_storage_path('completion', 'commands.json'); } - public function getHelperBinaryPath(): string - { - return internal_storage_path('completion', $this->getHelperBinaryFilename()); - } - - public function getHelperBinaryAssetFilename(): string - { - return $this->getHelperBinaryFilename() . '_' . str_replace('-', '_', $this->getHelperBinaryPlatform()); - } - - public function getHelperBinaryReleaseTag(): string - { - $releaseTag = $this->resolveInstalledReleaseVersion(); - - if (str_contains($releaseTag, 'dev')) { - throw new RuntimeException("Completion helper binaries are only available for tagged releases. Current version is `{$releaseTag}`."); - } - - if (str_starts_with($releaseTag, 'v')) { - return $releaseTag; - } - - return "v{$releaseTag}"; - } - - public function getHelperBinaryDownloadUrl(): string - { - return sprintf( - self::RELEASE_DOWNLOAD_BASE_URL . '/%s/%s', - $this->getHelperBinaryReleaseTag(), - $this->getHelperBinaryAssetFilename(), - ); - } - - public function isSupportedPlatform(?string $osFamily = null, ?string $architecture = null): bool + public function isSupportedPlatform(?string $osFamily = null): bool { $osFamily ??= PHP_OS_FAMILY; - $architecture ??= strtolower((string) php_uname('m')); return match ($osFamily) { - 'Darwin' => $architecture === 'arm64', - 'Linux' => true, + 'Darwin', 'Linux' => true, default => false, }; } public function getUnsupportedPlatformMessage(): string { - return 'Completion commands are supported on Linux and macOS (Apple Silicon). Use WSL if you are on Windows.'; - } - - public function getHelperBinaryPlatform(): string - { - $os = match (PHP_OS_FAMILY) { - 'Darwin' => 'darwin', - 'Linux' => 'linux', - default => strtolower(PHP_OS_FAMILY), - }; - - $architecture = match (strtolower((string) php_uname('m'))) { - 'amd64', 'x86_64' => 'x86_64', - 'aarch64', 'arm64' => 'arm64', - default => strtolower((string) php_uname('m')), - }; - - return "{$os}-{$architecture}"; - } - - public function getHelperBinaryFilename(): string - { - return 'tempest-complete'; + return 'Completion commands are supported on Linux and macOS. Use WSL if you are on Windows.'; } public function getInstalledCompletionPath(Shell $shell): string @@ -134,27 +67,6 @@ public function getPostInstallInstructions(Shell $shell): array }; } - private function resolveInstalledReleaseVersion(): string - { - if (! class_exists(\Composer\InstalledVersions::class)) { - throw new RuntimeException('Unable to determine the installed Tempest version to download completion helper binaries.'); - } - - foreach (['tempest/framework', 'tempest/console'] as $package) { - if (! \Composer\InstalledVersions::isInstalled($package)) { - continue; - } - - $version = \Composer\InstalledVersions::getPrettyVersion($package); - - if (is_string($version) && $version !== '') { - return $version; - } - } - - throw new RuntimeException('Unable to determine the installed Tempest version to download completion helper binaries.'); - } - private function getProfileDirectory(): string { $profileDirectory = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? getenv('HOME') ?: null; diff --git a/packages/console/src/completion.bash b/packages/console/src/completion.bash index 1fe6b162b..83644916b 100644 --- a/packages/console/src/completion.bash +++ b/packages/console/src/completion.bash @@ -157,7 +157,7 @@ _tempest() { project_directory="$(_tempest_project_directory "$command")" || return 0 - helper="$project_directory/.tempest/completion/tempest-complete" + helper="$project_directory/vendor/bin/tempest-complete" metadata="$project_directory/.tempest/completion/commands.json" [[ -x "$helper" ]] || return 0 diff --git a/packages/console/src/completion.zsh b/packages/console/src/completion.zsh index 553b16cfa..b400e8e1d 100644 --- a/packages/console/src/completion.zsh +++ b/packages/console/src/completion.zsh @@ -129,7 +129,7 @@ if (( $+functions[compdef] )); then _tempest_project_directory "$command" project_directory="$REPLY" - helper="${project_directory}/.tempest/completion/tempest-complete" + helper="${project_directory}/vendor/bin/tempest-complete" metadata="${project_directory}/.tempest/completion/commands.json" [[ -x "$helper" ]] || return 0 From 5f811ec0de39138bd102df7cbd0ba90ef6d3777b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:34:46 +0100 Subject: [PATCH 24/30] test(console): update tests for PHP completion helper --- .../console/tests/CompletionRuntimeTest.php | 30 ++--------- .../CompletionGenerateCommandTest.php | 17 ------- .../Commands/CompletionInstallCommandTest.php | 22 ++------ .../CompletionUpdateBinCommandTest.php | 51 ------------------- 4 files changed, 8 insertions(+), 112 deletions(-) delete mode 100644 tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index 83de2897a..d409e8f25 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -30,19 +30,17 @@ protected function setUp(): void #[Test] #[DataProvider('supportedPlatformDataProvider')] - public function isSupportedPlatform(string $osFamily, string $architecture, bool $expected): void + public function isSupportedPlatform(string $osFamily, bool $expected): void { - $this->assertSame($expected, $this->completionRuntime->isSupportedPlatform($osFamily, $architecture)); + $this->assertSame($expected, $this->completionRuntime->isSupportedPlatform($osFamily)); } public static function supportedPlatformDataProvider(): array { return [ - 'linux x86_64' => ['Linux', 'x86_64', true], - 'linux arm64' => ['Linux', 'arm64', true], - 'darwin arm64' => ['Darwin', 'arm64', true], - 'darwin x86_64' => ['Darwin', 'x86_64', false], - 'windows' => ['Windows', 'x86_64', false], + 'linux' => ['Linux', true], + 'darwin' => ['Darwin', true], + 'windows' => ['Windows', false], ]; } @@ -52,24 +50,6 @@ public function getUnsupportedPlatformMessage(): void $this->assertStringContainsString('Windows', $this->completionRuntime->getUnsupportedPlatformMessage()); } - #[Test] - public function getHelperBinaryAssetFilename(): void - { - $this->assertMatchesRegularExpression( - '/^tempest-complete_[a-z0-9]+_[a-z0-9_]+$/', - $this->completionRuntime->getHelperBinaryAssetFilename(), - ); - } - - #[Test] - public function getHelperBinaryReleaseTag_throws_for_dev_versions(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('tagged releases'); - - $this->completionRuntime->getHelperBinaryReleaseTag(); - } - #[Test] public function getInstallationDirectory(): void { diff --git a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php index 3129230b7..dd86cc595 100644 --- a/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionGenerateCommandTest.php @@ -59,7 +59,6 @@ public function generate_writes_completion_metadata_to_default_path(): void $this->assertSame(['--flag', '--items=', '--value='], $flags); $this->assertSame('Install shell completion for Tempest', $metadata['commands']['completion:install']['description']); - $this->assertSame('Update the completion helper binary', $metadata['commands']['completion:update-bin']['description']); $this->assertSame(['-s'], $installFlags['shell']['aliases']); $this->assertSame('The shell to install completions for (zsh, bash)', $installFlags['shell']['description']); $this->assertSame(['bash', 'zsh'], $installFlags['shell']['value_options']); @@ -78,20 +77,4 @@ public function generate_writes_completion_metadata_to_custom_path(): void $this->assertTrue(Filesystem\is_file($this->generatedPath)); } - - #[Test] - public function generate_does_not_create_runtime_helper_binary(): void - { - $helperBinary = $this->completionRuntime->getHelperBinaryPath(); - - if (Filesystem\is_file($helperBinary)) { - Filesystem\delete_file($helperBinary); - } - - $this->console - ->call('completion:generate') - ->assertSuccess(); - - $this->assertFalse(Filesystem\is_file($helperBinary)); - } } diff --git a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php index ae7578110..f6439ac31 100644 --- a/tests/Integration/Console/Commands/CompletionInstallCommandTest.php +++ b/tests/Integration/Console/Commands/CompletionInstallCommandTest.php @@ -19,8 +19,6 @@ final class CompletionInstallCommandTest extends FrameworkIntegrationTestCase private ?string $metadataFile = null; - private ?string $helperBinary = null; - private string $profileDirectory; private ?string $originalHome = null; @@ -58,11 +56,6 @@ protected function tearDown(): void $this->metadataFile = null; } - if ($this->helperBinary !== null && Filesystem\is_file($this->helperBinary)) { - Filesystem\delete_file($this->helperBinary); - $this->helperBinary = null; - } - if ($this->originalHome === null) { putenv('HOME'); unset($_ENV['HOME'], $_SERVER['HOME']); @@ -89,7 +82,7 @@ public function install_with_explicit_shell_flag(): void $installedScript = Filesystem\read_file($this->installedFile); - $this->assertStringContainsString('/.tempest/completion/tempest-complete', $installedScript); + $this->assertStringContainsString('/vendor/bin/tempest-complete', $installedScript); $this->assertStringContainsString('/.tempest/completion/commands.json', $installedScript); } @@ -188,19 +181,10 @@ public function install_overwrites_existing_file_when_user_accepts_overwrite_def ->assertSuccess(); } - private function prepareCompletionRuntime(bool $withRuntimeHelperBinary = true): void + private function prepareCompletionRuntime(): void { - $directory = $this->completionRuntime->getDirectory(); - - Filesystem\ensure_directory_exists($directory); - $this->metadataFile = $this->completionRuntime->getMetadataPath(); + Filesystem\ensure_directory_exists(dirname($this->metadataFile)); Filesystem\write_json($this->metadataFile, ['version' => 1, 'commands' => []]); - - if ($withRuntimeHelperBinary) { - $this->helperBinary = $this->completionRuntime->getHelperBinaryPath(); - Filesystem\write_file($this->helperBinary, "#!/bin/sh\nexit 0\n"); - chmod($this->helperBinary, 0o755); - } } } diff --git a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php b/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php deleted file mode 100644 index 4c6a74dec..000000000 --- a/tests/Integration/Console/Commands/CompletionUpdateBinCommandTest.php +++ /dev/null @@ -1,51 +0,0 @@ -markTestSkipped('Shell completion is not supported on Windows.'); - } - - $this->completionRuntime = new CompletionRuntime(); - } - - protected function tearDown(): void - { - if ($this->helperBinary !== null && Filesystem\is_file($this->helperBinary)) { - Filesystem\delete_file($this->helperBinary); - $this->helperBinary = null; - } - - parent::tearDown(); - } - - #[Test] - public function update_bin_fails_gracefully_on_dev_version(): void - { - $this->helperBinary = $this->completionRuntime->getHelperBinaryPath(); - - $this->console - ->call('completion:update-bin') - ->assertError(); - } -} From fad862c174fe3652bcdbbb41e4283f88ecf5fc96 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:36:41 +0100 Subject: [PATCH 25/30] style(console): add spaces --- packages/console/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/composer.json b/packages/console/composer.json index c1cd4013c..21f4d7366 100644 --- a/packages/console/composer.json +++ b/packages/console/composer.json @@ -32,6 +32,6 @@ }, "bin": [ "bin/tempest", - "bin/tempest-complete" + "bin/tempest-complete" ] } From 679f5cb68a5394058ee8b19d2ac890fef196b52d Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:37:10 +0100 Subject: [PATCH 26/30] chore(console): update gitignore --- packages/console/.gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/console/.gitignore b/packages/console/.gitignore index f771ccda3..995cecc53 100644 --- a/packages/console/.gitignore +++ b/packages/console/.gitignore @@ -1,5 +1,3 @@ console.log debug.log tempest.log -completion-helper/target/ -tempest-complete_* From 187b92d2b8fc16e9fc0c633c88e9d174fe6d5455 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 13:40:44 +0100 Subject: [PATCH 27/30] docs: update completion documentation --- docs/1-essentials/04-console-commands.md | 26 ++++++++---------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/docs/1-essentials/04-console-commands.md b/docs/1-essentials/04-console-commands.md index 7e5bd711f..08d0d5554 100644 --- a/docs/1-essentials/04-console-commands.md +++ b/docs/1-essentials/04-console-commands.md @@ -240,9 +240,9 @@ Interactive components are only supported on Mac and Linux. On Windows, Tempest ## Shell completion -Tempest provides shell completion for Zsh and Bash on Linux and macOS (Apple Silicon). This allows you to press `Tab` to autocomplete command names and options. On Windows, use WSL. +Tempest provides shell completion for Zsh and Bash on Linux and macOS. This allows you to press `Tab` to autocomplete command names and options. On Windows, use WSL. -Completion relies on two things: a **completion script** sourced by your shell, and a **helper binary** that performs the actual matching. The helper binary is not bundled with Tempest. It is downloaded from the GitHub release matching your installed Tempest version. +Completion relies on two things: a **completion script** sourced by your shell, and a **helper executable** (`vendor/bin/tempest-complete`) that performs the actual matching. ### Installing completions @@ -256,8 +256,7 @@ This will: 1. Detect your shell (or use `--shell=zsh` / `--shell=bash`). 2. Generate completion metadata (`commands.json`) for all registered commands. -3. Download the platform-specific helper binary from the matching Tempest release. -4. Install the completion script to the appropriate location. +3. Install the completion script to the appropriate location. After installation, add the following line to your shell configuration file and restart your terminal: @@ -277,21 +276,14 @@ After adding or removing commands, regenerate the metadata: ./tempest completion:generate ``` -After updating Tempest to a new version, update the helper binary: - -```console -./tempest completion:update-bin -``` - ### Available commands -| Command | Description | -|-------------------------|--------------------------------------------------------------------------| -| `completion:install` | Install the completion script and download the helper binary. | -| `completion:generate` | Regenerate the completion metadata JSON. | -| `completion:update-bin` | Re-download the helper binary for the current Tempest version. | -| `completion:show` | Output the completion script to stdout (useful for custom installation). | -| `completion:uninstall` | Remove the installed completion script. | +| Command | Description | +|------------------------|--------------------------------------------------------------------------| +| `completion:install` | Install the completion script and generate metadata. | +| `completion:generate` | Regenerate the completion metadata JSON. | +| `completion:show` | Output the completion script to stdout (useful for custom installation). | +| `completion:uninstall` | Remove the installed completion script. | ## Middleware From 04bbf5d65717e0eef690b6e154dcaab4555d662a Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 16 Feb 2026 15:01:59 +0100 Subject: [PATCH 28/30] test(console): skip windows --- packages/console/tests/CompletionHelperPhpTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/console/tests/CompletionHelperPhpTest.php b/packages/console/tests/CompletionHelperPhpTest.php index 5398f9218..4940f6095 100644 --- a/packages/console/tests/CompletionHelperPhpTest.php +++ b/packages/console/tests/CompletionHelperPhpTest.php @@ -23,6 +23,10 @@ protected function setUp(): void { parent::setUp(); + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('Shell completion is not supported on Windows.'); + } + $this->engine = new CompletionEngine(); $this->inputNormalizer = new CompletionInputNormalizer(); $this->metadataParser = new CompletionMetadataParser(); From 401f3e5d3f869bc2ebedbd3c17c07ebb931187a4 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 14:09:41 +0100 Subject: [PATCH 29/30] fix(console): static analysis issues --- packages/console/src/Actions/BuildCompletionMetadata.php | 2 +- packages/console/src/CompletionRuntime.php | 6 +++--- packages/console/tests/CompletionRuntimeTest.php | 2 -- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/console/src/Actions/BuildCompletionMetadata.php b/packages/console/src/Actions/BuildCompletionMetadata.php index 201147273..11bdef49f 100644 --- a/packages/console/src/Actions/BuildCompletionMetadata.php +++ b/packages/console/src/Actions/BuildCompletionMetadata.php @@ -88,7 +88,7 @@ private function buildValueOptions(ConsoleArgumentDefinition $definition): array return []; } - /** @var BackedEnum $type */ + /** @var class-string $type */ $type = $definition->type; $options = array_map( diff --git a/packages/console/src/CompletionRuntime.php b/packages/console/src/CompletionRuntime.php index 58ddc141d..adbc4720e 100644 --- a/packages/console/src/CompletionRuntime.php +++ b/packages/console/src/CompletionRuntime.php @@ -71,15 +71,15 @@ private function getProfileDirectory(): string { $profileDirectory = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? getenv('HOME') ?: null; - if ($profileDirectory === null || $profileDirectory === '') { + if ($profileDirectory === null) { $profileDirectory = $_SERVER['USERPROFILE'] ?? $_ENV['USERPROFILE'] ?? getenv('USERPROFILE') ?: null; } - if (($profileDirectory === null || $profileDirectory === '') && getenv('HOMEDRIVE') !== false && getenv('HOMEPATH') !== false) { + if ($profileDirectory === null && getenv('HOMEDRIVE') !== false && getenv('HOMEPATH') !== false) { $profileDirectory = getenv('HOMEDRIVE') . getenv('HOMEPATH'); } - if ($profileDirectory === null || $profileDirectory === '') { + if ($profileDirectory === null) { throw new RuntimeException('Could not determine user profile directory for completions.'); } diff --git a/packages/console/tests/CompletionRuntimeTest.php b/packages/console/tests/CompletionRuntimeTest.php index d409e8f25..2281f179f 100644 --- a/packages/console/tests/CompletionRuntimeTest.php +++ b/packages/console/tests/CompletionRuntimeTest.php @@ -71,12 +71,10 @@ public function getInstalledCompletionPath(): void public function getPostInstallInstructions(): void { $zshInstructions = $this->completionRuntime->getPostInstallInstructions(Shell::ZSH); - $this->assertIsArray($zshInstructions); $this->assertNotEmpty($zshInstructions); $this->assertStringContainsStringIgnoringCase('source', implode("\n", $zshInstructions)); $bashInstructions = $this->completionRuntime->getPostInstallInstructions(Shell::BASH); - $this->assertIsArray($bashInstructions); $this->assertNotEmpty($bashInstructions); $this->assertStringContainsStringIgnoringCase('source', implode("\n", $bashInstructions)); } From 22000b97f1cfb179cb6e04be5b9f9780b2f2731c Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 17 Feb 2026 15:52:04 +0100 Subject: [PATCH 30/30] chore: add codeowner entry for completion --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7070e39c6..a416c1717 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,7 @@ /packages/clock/ @aidan-casey /packages/command-bus/ @brendt @aidan-casey /packages/console/ @brendt +/packages/console/src/Completion @xHeaven /packages/container/ @brendt /packages/core/ @brendt /packages/cryptography/ @innocenzi