Skip to content

Commit 05dac5c

Browse files
authored
feat(console): several QOL improvements (#847)
1 parent 751f0d1 commit 05dac5c

File tree

15 files changed

+181
-40
lines changed

15 files changed

+181
-40
lines changed

src/Tempest/Console/src/Components/Interactive/SingleChoiceComponent.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Console\Components\Interactive;
66

7+
use Stringable;
78
use Tempest\Console\Components\Concerns\HasErrors;
89
use Tempest\Console\Components\Concerns\HasState;
910
use Tempest\Console\Components\Concerns\HasTextBuffer;
@@ -128,7 +129,7 @@ public function input(string $key): void
128129
}
129130

130131
#[HandlesKey(Key::ENTER)]
131-
public function enter(): null|int|string
132+
public function enter(): null|int|string|Stringable
132133
{
133134
$active = $this->options->getActive();
134135

src/Tempest/Console/src/Console.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use BackedEnum;
88
use Closure;
9+
use Stringable;
910
use Tempest\Highlight\Language;
1011
use Tempest\Support\ArrayHelper;
1112

@@ -42,7 +43,7 @@ public function ask(
4243
?string $placeholder = null,
4344
?string $hint = null,
4445
array $validation = [],
45-
): null|int|string|array;
46+
): null|int|string|Stringable|array;
4647

4748
public function confirm(string $question, bool $default = false, ?string $yes = null, ?string $no = null): bool;
4849

src/Tempest/Console/src/ConsoleCommand.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Attribute;
88
use Tempest\Console\Input\ConsoleArgumentDefinition;
99
use Tempest\Reflection\MethodReflector;
10+
use function Tempest\Support\str;
1011

1112
#[Attribute]
1213
final class ConsoleCommand
@@ -43,9 +44,15 @@ public function getName(): string
4344
return $this->name;
4445
}
4546

47+
$commandName = str($this->handler->getDeclaringClass()->getShortName())
48+
->replaceEnd('ConsoleCommand', '')
49+
->replaceEnd('Command', '')
50+
->snake(':')
51+
->lower();
52+
4653
return $this->handler->getName() === '__invoke'
47-
? strtolower($this->handler->getDeclaringClass()->getShortName())
48-
: strtolower($this->handler->getDeclaringClass()->getShortName() . ':' . $this->handler->getName());
54+
? $commandName->toString()
55+
: strtolower($commandName . ':' . $this->handler->getName());
4956
}
5057

5158
/**

src/Tempest/Console/src/GenericConsole.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use BackedEnum;
88
use Closure;
9+
use Stringable;
910
use Tempest\Console\Actions\ExecuteConsoleCommand;
1011
use Tempest\Console\Components\Interactive\ConfirmComponent;
1112
use Tempest\Console\Components\Interactive\MultipleChoiceComponent;
@@ -195,7 +196,7 @@ public function ask(
195196
?string $placeholder = null,
196197
?string $hint = null,
197198
array $validation = [],
198-
): null|int|string|array {
199+
): null|int|string|Stringable|array {
199200
if ($this->isForced && $default) {
200201
return $default;
201202
}

src/Tempest/Console/src/Middleware/InvalidCommandMiddleware.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use Tempest\Console\Exceptions\InvalidCommandException;
1313
use Tempest\Console\ExitCode;
1414
use Tempest\Console\Initializers\Invocation;
15-
use Tempest\Console\Input\ConsoleArgumentDefinition;
1615
use Tempest\Console\Input\ConsoleInputArgument;
1716
use Tempest\Validation\Rules\Boolean;
1817
use Tempest\Validation\Rules\Enum;
@@ -44,16 +43,16 @@ private function retry(Invocation $invocation, InvalidCommandException $exceptio
4443
subheader: $invocation->consoleCommand->description,
4544
);
4645

47-
/** @var ConsoleArgumentDefinition $argument */
4846
foreach ($exception->invalidArguments as $argument) {
4947
$isEnum = is_a($argument->type, BackedEnum::class, allow_string: true);
48+
5049
$value = $this->console->ask(
5150
question: str($argument->name)->snake(' ')->upperFirst()->toString(),
52-
default: (string) $argument->default,
53-
hint: $argument->help ?? $argument->description,
5451
options: $isEnum
5552
? $argument->type
5653
: null,
54+
default: (string) $argument->default,
55+
hint: $argument->help ?? $argument->description,
5756
validation: array_filter([
5857
$isEnum
5958
? new Enum($argument->type)

src/Tempest/Console/src/Middleware/OverviewMiddleware.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ private function renderOverview(bool $showHidden = false): void
8383
->writeln($title);
8484

8585
foreach ($commandsForGroup as $consoleCommand) {
86-
(new RenderConsoleCommand($this->console, $longestCommandName))($consoleCommand);
86+
new RenderConsoleCommand($this->console, $longestCommandName)($consoleCommand);
8787
}
8888
}
8989

src/Tempest/Console/src/Middleware/ResolveOrRescueMiddleware.php

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44

55
namespace Tempest\Console\Middleware;
66

7+
use Stringable;
78
use Tempest\Console\Actions\ExecuteConsoleCommand;
89
use Tempest\Console\Actions\ResolveConsoleCommand;
910
use Tempest\Console\Console;
10-
use Tempest\Console\ConsoleCommand;
1111
use Tempest\Console\ConsoleConfig;
1212
use Tempest\Console\ConsoleMiddleware;
1313
use Tempest\Console\ConsoleMiddlewareCallable;
1414
use Tempest\Console\ExitCode;
1515
use Tempest\Console\Initializers\Invocation;
1616
use Tempest\Support\ArrayHelper;
17+
use Tempest\Support\StringHelper;
1718
use Throwable;
19+
use function Tempest\Support\arr;
20+
use function Tempest\Support\str;
1821

1922
final readonly class ResolveOrRescueMiddleware implements ConsoleMiddleware
2023
{
@@ -46,15 +49,17 @@ private function rescue(string $commandName): ExitCode|int
4649
$this->console->writeln('<style="bg-dark-red fg-white"> Error </style>');
4750
$this->console->writeln("<style=\"fg-red\">Command <em>{$commandName}</em> not found.</style>");
4851

49-
$similarCommands = $this->getSimilarCommands($commandName);
52+
$similarCommands = $this->getSimilarCommands(str($commandName));
5053

51-
if ($similarCommands === []) {
54+
if ($similarCommands->isEmpty()) {
5255
return ExitCode::ERROR;
5356
}
5457

55-
if (count($similarCommands) === 1) {
56-
if ($this->console->confirm("Did you mean <em>{$similarCommands[0]}</em>?")) {
57-
return $this->runIntendedCommand($similarCommands[0]);
58+
if ($similarCommands->count() === 1) {
59+
$matchedCommand = $similarCommands->first();
60+
61+
if ($this->console->confirm("Did you mean <em>{$matchedCommand}</em>?", default: true)) {
62+
return $this->runIntendedCommand($matchedCommand);
5863
}
5964

6065
return ExitCode::CANCELLED;
@@ -68,45 +73,88 @@ private function rescue(string $commandName): ExitCode|int
6873
return $this->runIntendedCommand($intendedCommand);
6974
}
7075

71-
private function getSimilarCommands(string $name): array
76+
private function getSimilarCommands(StringHelper $search): ArrayHelper
7277
{
73-
$similarCommands = [];
78+
/** @var ArrayHelper<array-key, StringHelper> $suggestions */
79+
$suggestions = arr();
7480

75-
/** @var ConsoleCommand $consoleCommand */
7681
foreach ($this->consoleConfig->commands as $consoleCommand) {
77-
if (in_array($consoleCommand->getName(), $similarCommands, strict: true)) {
82+
$currentName = str($consoleCommand->getName());
83+
84+
// Already added to suggestions
85+
if ($suggestions->contains($currentName->toString())) {
7886
continue;
7987
}
8088

81-
if (str_contains($name, ':')) {
82-
$wantedParts = ArrayHelper::explode($name, separator: ':');
83-
$currentParts = ArrayHelper::explode($consoleCommand->getName(), separator: ':');
89+
$currentParts = $currentName->explode(':');
90+
$searchParts = $search->explode(':');
8491

85-
if ($wantedParts->count() === $currentParts->count() && $wantedParts->every(fn (string $part, int $index) => str_starts_with($currentParts[$index], $part))) {
86-
$similarCommands[] = $consoleCommand->getName();
92+
// `dis:st` will match `discovery:status`
93+
if ($searchParts->count() === $currentParts->count()) {
94+
if ($searchParts->every(fn (string $part, int $index) => str_starts_with($currentParts[$index], $part))) {
95+
$suggestions[] = $currentName;
8796

8897
continue;
8998
}
9099
}
91100

92-
if (str_starts_with($consoleCommand->getName(), $name)) {
93-
$similarCommands[] = $consoleCommand->getName();
101+
// `generate` will match `discovery:generate`
102+
if ($currentName->startsWith($search) || $currentName->endsWith($search)) {
103+
$suggestions[] = $currentName;
94104

95105
continue;
96106
}
97107

98-
$levenshtein = levenshtein($name, $consoleCommand->getName());
108+
// Match with levenshtein on the whole command
109+
if ($currentName->levenshtein($search) <= 2) {
110+
$suggestions[] = $currentName;
99111

100-
if ($levenshtein <= 2) {
101-
$similarCommands[] = $consoleCommand->getName();
112+
continue;
102113
}
114+
115+
// Match with levenshtein on each command part
116+
foreach ($currentParts as $part) {
117+
$part = str($part);
118+
119+
// `clean` will match `static:clean` but also `discovery:clear`
120+
if ($part->levenshtein($search) <= 1) {
121+
$suggestions[] = $currentName;
122+
123+
continue 2;
124+
}
125+
126+
// `generate` will match `discovery:generate`
127+
if ($part->startsWith($search)) {
128+
$suggestions[] = $currentName;
129+
130+
continue 2;
131+
}
132+
}
133+
}
134+
135+
// Sort with levenshtein
136+
$sorted = arr();
137+
138+
foreach ($suggestions as $suggestion) {
139+
// Calculate the levenshtein difference on the whole suggestion
140+
$levenshtein = $suggestion->levenshtein($search);
141+
142+
// Calculate the levenshtein difference on each part of the suggestion
143+
foreach ($suggestion->explode(':') as $suggestionPart) {
144+
// Always use the closest distance
145+
$levenshtein = min($levenshtein, str($suggestionPart)->levenshtein($search));
146+
}
147+
148+
$sorted[] = ['levenshtein' => $levenshtein, 'suggestion' => $suggestion];
103149
}
104150

105-
return $similarCommands;
151+
return $sorted
152+
->sortByCallback(fn (array $a, array $b) => $a['levenshtein'] <=> $b['levenshtein'])
153+
->map(fn (array $item) => $item['suggestion']);
106154
}
107155

108-
private function runIntendedCommand(string $commandName): ExitCode|int
156+
private function runIntendedCommand(Stringable $commandName): ExitCode|int
109157
{
110-
return ($this->executeConsoleCommand)($commandName);
158+
return ($this->executeConsoleCommand)((string) $commandName);
111159
}
112160
}

src/Tempest/Support/src/IsIterable.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ public function offsetGet(mixed $offset): mixed
4444

4545
public function offsetSet(mixed $offset, mixed $value): void
4646
{
47-
$this->array[$offset] = $value;
47+
if ($offset === null) {
48+
$this->array[] = $value;
49+
} else {
50+
$this->array[$offset] = $value;
51+
}
4852
}
4953

5054
public function offsetUnset(mixed $offset): void

src/Tempest/Support/src/StringHelper.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,20 @@ public function substr(int $start, ?int $length = null): self
715715
return new self(mb_substr($this->string, $start, $length));
716716
}
717717

718+
/**
719+
* Checks whether this string contains another string
720+
*
721+
* ### Example
722+
* ```php
723+
* str('Lorem ipsum')->contains('ipsum'); // true
724+
* str('Lorem ipsum')->contains('something else'); // false
725+
* ```
726+
*/
727+
public function contains(string|Stringable $needle): bool
728+
{
729+
return str_contains($this->string, (string) $needle);
730+
}
731+
718732
/**
719733
* Takes the specified amount of characters. If `$length` is negative, starts from the end.
720734
*/
@@ -856,6 +870,20 @@ public function insertAt(int $position, string $string): self
856870
);
857871
}
858872

873+
/**
874+
* Calculates the levenshtein difference
875+
*
876+
*
877+
* ### Example
878+
* ```php
879+
* str('Foo')->levenshtein('Foobar');
880+
* ```
881+
*/
882+
public function levenshtein(string|Stringable $other): int
883+
{
884+
return levenshtein($this->string, (string) $other);
885+
}
886+
859887
public function when(mixed $condition, Closure $callback): static
860888
{
861889
if ($condition instanceof Closure) {

src/Tempest/Support/tests/StringHelperTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,4 +608,16 @@ public function test_to_html_string(): void
608608
$this->assertInstanceOf(HtmlString::class, str('foo')->toHtmlString());
609609
$this->assertSame('foo', (string) str('foo')->toHtmlString());
610610
}
611+
612+
public function test_contains(): void
613+
{
614+
$this->assertTrue(str('foo')->contains('fo'));
615+
$this->assertFalse(str('foo')->contains('bar'));
616+
}
617+
618+
public function test_levenshtein(): void
619+
{
620+
$this->assertSame(0, str('foo')->levenshtein('foo'));
621+
$this->assertSame(3, str('foo')->levenshtein('bar'));
622+
}
611623
}

0 commit comments

Comments
 (0)