• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tempestphp / tempest-framework / 14049246919

24 Mar 2025 09:42PM UTC coverage: 79.353% (-0.04%) from 79.391%
14049246919

push

github

web-flow
feat(support): support array parameters in string manipulations (#1073)

48 of 48 new or added lines in 2 files covered. (100.0%)

735 existing lines in 126 files now uncovered.

10492 of 13222 relevant lines covered (79.35%)

90.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.63
/src/Tempest/Console/src/Middleware/ResolveOrRescueMiddleware.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Tempest\Console\Middleware;
6

7
use Stringable;
8
use Tempest\Console\Actions\ExecuteConsoleCommand;
9
use Tempest\Console\Actions\ResolveConsoleCommand;
10
use Tempest\Console\Console;
11
use Tempest\Console\ConsoleConfig;
12
use Tempest\Console\ConsoleMiddleware;
13
use Tempest\Console\ConsoleMiddlewareCallable;
14
use Tempest\Console\ExitCode;
15
use Tempest\Console\Initializers\Invocation;
16
use Tempest\Support\Arr\ImmutableArray;
17
use Tempest\Support\Str\ImmutableString;
18
use Throwable;
19

20
use function Tempest\Support\arr;
21
use function Tempest\Support\str;
22

23
final readonly class ResolveOrRescueMiddleware implements ConsoleMiddleware
24
{
25
    public function __construct(
102✔
26
        private ConsoleConfig $consoleConfig,
27
        private Console $console,
28
        private ExecuteConsoleCommand $executeConsoleCommand,
29
        private ResolveConsoleCommand $resolveConsoleCommand,
30
    ) {}
102✔
31

32
    public function __invoke(Invocation $invocation, ConsoleMiddlewareCallable $next): ExitCode|int
102✔
33
    {
34
        try {
35
            $consoleCommand = ($this->resolveConsoleCommand)($invocation->argumentBag->getCommandName());
102✔
36
        } catch (Throwable) {
3✔
37
            return $this->rescue($invocation->argumentBag->getCommandName());
3✔
38
        }
39

40
        return $next(new Invocation(
99✔
41
            argumentBag: $invocation->argumentBag,
99✔
42
            consoleCommand: $consoleCommand,
99✔
43
        ));
99✔
44
    }
45

46
    private function rescue(string $commandName): ExitCode|int
3✔
47
    {
48
        $this->console->writeln();
3✔
49
        $this->console->writeln('<style="bg-dark-red fg-white"> Error </style>');
3✔
50
        $this->console->writeln("<style='fg-red'>Command <em>{$commandName}</em> not found.</style>");
3✔
51

52
        $similarCommands = $this->getSimilarCommands(str($commandName));
3✔
53

54
        if ($similarCommands->isEmpty()) {
3✔
55
            return ExitCode::ERROR;
1✔
56
        }
57

58
        if ($similarCommands->count() === 1) {
2✔
59
            $matchedCommand = $similarCommands->first();
1✔
60

61
            if ($this->console->confirm("Did you mean <em>{$matchedCommand}</em>?", default: true)) {
1✔
UNCOV
62
                return $this->runIntendedCommand($matchedCommand);
×
63
            }
64

UNCOV
65
            return ExitCode::CANCELLED;
×
66
        }
67

68
        $intendedCommand = $this->console->ask(
2✔
69
            'Did you mean to run one of these?',
2✔
70
            options: $similarCommands,
2✔
71
        );
2✔
72

UNCOV
73
        return $this->runIntendedCommand($intendedCommand);
×
74
    }
75

76
    private function getSimilarCommands(ImmutableString $search): ImmutableArray
3✔
77
    {
78
        /** @var ImmutableArray<array-key, ImmutableString> $suggestions */
79
        $suggestions = arr();
3✔
80

81
        foreach ($this->consoleConfig->commands as $consoleCommand) {
3✔
82
            $currentName = str($consoleCommand->getName());
3✔
83

84
            // Already added to suggestions
85
            if ($suggestions->contains($currentName->toString())) {
3✔
UNCOV
86
                continue;
×
87
            }
88

89
            $currentParts = $currentName->explode(':');
3✔
90
            $searchParts = $search->explode(':');
3✔
91

92
            // `dis:st` will match `discovery:status`
93
            if ($searchParts->count() === $currentParts->count()) {
3✔
94
                if ($searchParts->every(fn (string $part, int $index) => str_starts_with($currentParts[$index], $part))) {
3✔
95
                    $suggestions[$currentName->toString()] = $currentName;
1✔
96

97
                    continue;
1✔
98
                }
99
            }
100

101
            // `generate` will match `discovery:generate`
102
            if ($currentName->startsWith($search) || $currentName->endsWith($search)) {
3✔
103
                $suggestions[$currentName->toString()] = $currentName;
2✔
104

105
                continue;
2✔
106
            }
107

108
            // Match with levenshtein on the whole command
109
            if ($currentName->levenshtein($search) <= 2) {
3✔
110
                $suggestions[$currentName->toString()] = $currentName;
1✔
111

112
                continue;
1✔
113
            }
114

115
            // Match with levenshtein on each command part
116
            foreach ($currentParts as $part) {
3✔
117
                $part = str($part);
3✔
118

119
                // `clean` will match `static:clean` but also `discovery:clear`
120
                if ($part->levenshtein($search) <= 1) {
3✔
121
                    $suggestions[$currentName->toString()] = $currentName;
1✔
122

123
                    continue 2;
1✔
124
                }
125

126
                // `generate` will match `discovery:generate`
127
                if ($part->startsWith($search)) {
3✔
128
                    $suggestions[$currentName->toString()] = $currentName;
1✔
129

130
                    continue 2;
1✔
131
                }
132
            }
133
        }
134

135
        // Sort with levenshtein
136
        $sorted = arr();
3✔
137

138
        foreach ($suggestions as $suggestion) {
3✔
139
            // Calculate the levenshtein difference on the whole suggestion
140
            $levenshtein = $suggestion->levenshtein($search);
2✔
141

142
            // Calculate the levenshtein difference on each part of the suggestion
143
            foreach ($suggestion->explode(':') as $suggestionPart) {
2✔
144
                // Always use the closest distance
145
                $levenshtein = min($levenshtein, str($suggestionPart)->levenshtein($search));
2✔
146
            }
147

148
            $sorted[] = ['levenshtein' => $levenshtein, 'suggestion' => $suggestion];
2✔
149
        }
150

151
        return $sorted
3✔
152
            ->sortByCallback(fn (array $a, array $b) => $a['levenshtein'] <=> $b['levenshtein'])
3✔
153
            ->map(fn (array $item) => $item['suggestion']);
3✔
154
    }
155

UNCOV
156
    private function runIntendedCommand(Stringable $commandName): ExitCode|int
×
157
    {
UNCOV
158
        return ($this->executeConsoleCommand)((string) $commandName);
×
159
    }
160
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc