Skip to content

Commit a1a7af0

Browse files
committed
feat(console): add native command completion for zsh and bash
1 parent 1af376a commit a1a7af0

File tree

6 files changed

+554
-41
lines changed

6 files changed

+554
-41
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Tempest\Console\ConsoleCommand;
8+
use Tempest\Console\Enums\Shell;
9+
use Tempest\Console\HasConsole;
10+
use Tempest\Console\ShellCompletionSupport;
11+
12+
use function Tempest\Support\Filesystem\create_directory;
13+
use function Tempest\Support\Filesystem\is_directory;
14+
use function Tempest\Support\Filesystem\is_file;
15+
use function Tempest\Support\Filesystem\read_file;
16+
use function Tempest\Support\Filesystem\write_file;
17+
use function Tempest\Support\str;
18+
19+
final class CompletionInstallCommand
20+
{
21+
use HasConsole;
22+
use ShellCompletionSupport;
23+
24+
#[ConsoleCommand(
25+
name: 'completion:install',
26+
description: 'Install shell completion for Tempest commands',
27+
)]
28+
public function __invoke(?string $shell = null): void
29+
{
30+
$targetShell = $this->resolveShell($shell);
31+
32+
if ($targetShell === null) {
33+
$this->error('Could not detect shell. Please specify one with --shell=bash or --shell=zsh');
34+
35+
return;
36+
}
37+
38+
if (! $this->confirm("Install completion for <em>{$targetShell->value}</em>?", default: true)) {
39+
$this->info('Installation cancelled.');
40+
41+
return;
42+
}
43+
44+
$method = $this->ask(
45+
question: 'How would you like to install completion?',
46+
options: [
47+
'source' => 'Source (add source line to shell config)',
48+
'copy' => 'Copy (copy script to completions directory)',
49+
'manual' => 'Manual (show instructions only)',
50+
],
51+
);
52+
53+
$success = match ($method) {
54+
'source' => $this->installWithSource($targetShell),
55+
'copy' => $this->installWithCopy($targetShell),
56+
'manual' => $this->showManualInstructions($targetShell),
57+
default => false,
58+
};
59+
60+
if ($success) {
61+
$this->writeln();
62+
$this->success('Shell completion installed successfully!');
63+
}
64+
65+
if ($success || $method === 'manual') {
66+
$this->showReloadInstructions($targetShell);
67+
}
68+
}
69+
70+
private function installWithSource(Shell $shell): bool
71+
{
72+
$rcFile = $shell->rcFile();
73+
$completionScriptPath = $this->getCompletionScriptPath($shell);
74+
$currentContent = $this->getRcContent($rcFile, $shell);
75+
76+
if ($currentContent === null) {
77+
return false;
78+
}
79+
80+
$sourceLine = self::COMPLETION_MARKER . PHP_EOL;
81+
$sourceLine .= "source \"{$completionScriptPath}\"" . PHP_EOL;
82+
83+
$newContent = rtrim($currentContent) . PHP_EOL . PHP_EOL . $sourceLine;
84+
85+
write_file($rcFile, $newContent);
86+
$this->info("Added source line to {$rcFile}");
87+
88+
return true;
89+
}
90+
91+
private function getRcContent(string $rcFile, Shell $shell): ?string
92+
{
93+
if (! is_file($rcFile)) {
94+
return '';
95+
}
96+
97+
$content = read_file($rcFile);
98+
99+
if (! str($content)->contains(self::COMPLETION_MARKER)) {
100+
return $content;
101+
}
102+
103+
$this->warning("Completion already installed in {$rcFile}");
104+
105+
if (! $this->confirm('Do you want to reinstall?', default: false)) {
106+
return null;
107+
}
108+
109+
return $content |> $this->removeCompletionLines(...);
110+
}
111+
112+
private function installWithCopy(Shell $shell): bool
113+
{
114+
$completionsDir = $shell->completionsDirectory();
115+
$destinationPath = $completionsDir . '/' . $shell->completionScriptName();
116+
$sourcePath = $this->getCompletionScriptPath($shell);
117+
118+
if (! $this->ensureDirectoryExists($completionsDir)) {
119+
return false;
120+
}
121+
122+
if (! $this->canWriteToDestination($destinationPath)) {
123+
return false;
124+
}
125+
126+
write_file($destinationPath, read_file($sourcePath));
127+
$this->info("Copied completion script to {$destinationPath}");
128+
129+
if ($shell === Shell::ZSH) {
130+
$this->ensureZshFpath($shell, $completionsDir);
131+
}
132+
133+
return true;
134+
}
135+
136+
private function ensureDirectoryExists(string $dir): bool
137+
{
138+
if (is_directory($dir)) {
139+
return true;
140+
}
141+
142+
if (! $this->confirm("Create directory {$dir}?", default: true)) {
143+
return false;
144+
}
145+
146+
create_directory($dir);
147+
148+
return true;
149+
}
150+
151+
private function canWriteToDestination(string $path): bool
152+
{
153+
if (! is_file($path)) {
154+
return true;
155+
}
156+
157+
$this->warning("File already exists: {$path}");
158+
159+
return $this->confirm('Do you want to overwrite it?', default: false);
160+
}
161+
162+
private function showManualInstructions(Shell $shell): bool
163+
{
164+
$completionScriptPath = $this->getCompletionScriptPath($shell);
165+
$rcFile = $shell->rcFile();
166+
$completionsDir = $shell->completionsDirectory();
167+
168+
$this->writeln();
169+
$this->header('Manual Installation Instructions');
170+
171+
$this->writeln();
172+
$this->writeln('<strong>Option 1: Source the completion script</strong>');
173+
$this->writeln("Add this line to your {$rcFile}:");
174+
$this->writeln();
175+
$this->writeln(" <em>source \"{$completionScriptPath}\"</em>");
176+
177+
$this->writeln();
178+
$this->writeln('<strong>Option 2: Copy to completions directory</strong>');
179+
$this->writeln('1. Create the completions directory (if needed):');
180+
$this->writeln(" <em>mkdir -p {$completionsDir}</em>");
181+
$this->writeln();
182+
$this->writeln('2. Copy the completion script:');
183+
$this->writeln(" <em>cp \"{$completionScriptPath}\" \"{$completionsDir}/{$shell->completionScriptName()}\"</em>");
184+
$this->writeln();
185+
186+
if ($shell === Shell::ZSH) {
187+
$this->writeln("3. Add to fpath in {$rcFile} (before compinit):");
188+
$this->writeln(" <em>fpath=({$completionsDir} \$fpath)</em>");
189+
}
190+
191+
return false;
192+
}
193+
194+
private function ensureZshFpath(Shell $shell, string $completionsDir): void
195+
{
196+
$rcFile = $shell->rcFile();
197+
$content = is_file($rcFile) ? read_file($rcFile) : '';
198+
$stringable = str($content);
199+
200+
if ($stringable->contains($completionsDir)) {
201+
$this->info("fpath already configured in {$rcFile}");
202+
203+
return;
204+
}
205+
206+
if ($stringable->contains(self::COMPLETION_MARKER)) {
207+
$this->warning('Completion marker found but fpath not configured. Updating...');
208+
$content = $content |> $this->removeCompletionLines(...);
209+
}
210+
211+
if (! $this->confirm("Add fpath configuration to {$rcFile}?", default: true)) {
212+
$this->writeln();
213+
$this->warning('You need to manually add this to your shell config:');
214+
$this->writeln(" <em>fpath=({$completionsDir} \$fpath)</em>");
215+
$this->writeln(' <em>autoload -Uz compinit && compinit</em>');
216+
217+
return;
218+
}
219+
220+
$fpathLine = self::COMPLETION_MARKER . PHP_EOL;
221+
$fpathLine .= "fpath=({$completionsDir} \$fpath)" . PHP_EOL;
222+
$fpathLine .= 'autoload -Uz compinit && compinit' . PHP_EOL;
223+
224+
write_file($rcFile, rtrim($content) . PHP_EOL . PHP_EOL . $fpathLine);
225+
$this->info("Added fpath configuration to {$rcFile}");
226+
}
227+
228+
private function showReloadInstructions(Shell $shell): void
229+
{
230+
$this->writeln();
231+
$this->info('To activate completion, either:');
232+
$this->writeln(' 1. Open a new terminal window, or');
233+
$this->writeln(" 2. Run: <em>source {$shell->rcFile()}</em>");
234+
}
235+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Tempest\Console\ConsoleCommand;
8+
use Tempest\Console\Enums\Shell;
9+
use Tempest\Console\HasConsole;
10+
use Tempest\Console\ShellCompletionSupport;
11+
12+
use function Tempest\Support\Filesystem\delete_file;
13+
use function Tempest\Support\Filesystem\is_file;
14+
use function Tempest\Support\Filesystem\read_file;
15+
use function Tempest\Support\Filesystem\write_file;
16+
use function Tempest\Support\str;
17+
18+
final class CompletionUninstallCommand
19+
{
20+
use HasConsole;
21+
use ShellCompletionSupport;
22+
23+
#[ConsoleCommand(
24+
name: 'completion:uninstall',
25+
description: 'Remove shell completion for Tempest commands',
26+
)]
27+
public function __invoke(?string $shell = null): void
28+
{
29+
$targetShell = $this->resolveShell($shell);
30+
31+
if ($targetShell === null) {
32+
$this->error('Could not detect shell. Please specify one with --shell=bash or --shell=zsh');
33+
34+
return;
35+
}
36+
37+
$installations = $this->detectInstallations($targetShell);
38+
39+
if ($installations === []) {
40+
$this->info("No completion installation found for {$targetShell->value}");
41+
42+
return;
43+
}
44+
45+
$this->writeln('Found completion installations:');
46+
47+
foreach ($installations as $type => $path) {
48+
$this->keyValue($type, $path);
49+
}
50+
51+
$this->writeln();
52+
53+
if (! $this->confirm('Remove all found installations?', default: true)) {
54+
$this->info('Uninstallation cancelled.');
55+
56+
return;
57+
}
58+
59+
$rcRemoved = ! isset($installations['rc']) || $this->removeRcInstallation($targetShell);
60+
$copyRemoved = ! isset($installations['copy']) || $this->removeCopiedFile($installations['copy']);
61+
62+
if ($rcRemoved && $copyRemoved) {
63+
$this->writeln();
64+
$this->success('Shell completion removed successfully!');
65+
$this->writeln('Reload your shell to apply changes.');
66+
}
67+
}
68+
69+
/**
70+
* @return array<string, string>
71+
*/
72+
private function detectInstallations(Shell $shell): array
73+
{
74+
$installations = [];
75+
76+
$rcFile = $shell->rcFile();
77+
78+
if (is_file($rcFile) && str(read_file($rcFile))->contains(self::COMPLETION_MARKER)) {
79+
$installations['rc'] = $rcFile;
80+
}
81+
82+
$copiedPath = $shell->completionsDirectory() . '/' . $shell->completionScriptName();
83+
84+
if (is_file($copiedPath) && $this->isTempestCompletionScript($copiedPath)) {
85+
$installations['copy'] = $copiedPath;
86+
}
87+
88+
return $installations;
89+
}
90+
91+
private function isTempestCompletionScript(string $path): bool
92+
{
93+
$content = str(read_file($path));
94+
95+
return $content->contains('tempest') || $content->contains('_sf_tempest');
96+
}
97+
98+
private function removeRcInstallation(Shell $shell): bool
99+
{
100+
$rcFile = $shell->rcFile();
101+
102+
if (! is_file($rcFile)) {
103+
return true;
104+
}
105+
106+
$newContent = read_file($rcFile)
107+
|> $this->removeCompletionLines(...)
108+
|> (static fn (string $c): string => preg_replace("/\n{3,}/", "\n\n", $c) ?? $c);
109+
110+
write_file($rcFile, $newContent);
111+
$this->info("Removed completion config from {$rcFile}");
112+
113+
return true;
114+
}
115+
116+
private function removeCopiedFile(string $path): bool
117+
{
118+
if (! is_file($path)) {
119+
return true;
120+
}
121+
122+
delete_file($path);
123+
$this->info("Removed completion script: {$path}");
124+
125+
return true;
126+
}
127+
}

0 commit comments

Comments
 (0)