44
55namespace Tempest \Console \Middleware ;
66
7+ use Stringable ;
78use Tempest \Console \Actions \ExecuteConsoleCommand ;
89use Tempest \Console \Actions \ResolveConsoleCommand ;
910use Tempest \Console \Console ;
10- use Tempest \Console \ConsoleCommand ;
1111use Tempest \Console \ConsoleConfig ;
1212use Tempest \Console \ConsoleMiddleware ;
1313use Tempest \Console \ConsoleMiddlewareCallable ;
1414use Tempest \Console \ExitCode ;
1515use Tempest \Console \Initializers \Invocation ;
1616use Tempest \Support \ArrayHelper ;
17+ use Tempest \Support \StringHelper ;
1718use Throwable ;
19+ use function Tempest \Support \arr ;
20+ use function Tempest \Support \str ;
1821
1922final 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}
0 commit comments