Skip to content

Commit 4c513ef

Browse files
[9.x] Reinvents route:list command (#40269)
* Improves CLI output of `route:list` command * Uses same vertical padding * Refactors mutation of routes * Fixes right padding on non-verbose mode * Fixes extra spacing * Displays name on non-verbose mode * minor formatting Co-authored-by: Taylor Otwell <[email protected]>
1 parent e6c8aae commit 4c513ef

File tree

4 files changed

+275
-27
lines changed

4 files changed

+275
-27
lines changed

src/Illuminate/Contracts/Routing/UrlGenerator.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ public function route($name, $parameters = [], $absolute = true);
6969
*/
7070
public function action($action, $parameters = [], $absolute = true);
7171

72+
/**
73+
* Get the root controller namespace.
74+
*
75+
* @return string
76+
*/
77+
public function getRootControllerNamespace();
78+
7279
/**
7380
* Set the root controller namespace.
7481
*

src/Illuminate/Foundation/Console/RouteListCommand.php

Lines changed: 152 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
use Closure;
66
use Illuminate\Console\Command;
7+
use Illuminate\Contracts\Routing\UrlGenerator;
78
use Illuminate\Routing\Route;
89
use Illuminate\Routing\Router;
10+
use Illuminate\Routing\ViewController;
911
use Illuminate\Support\Arr;
1012
use Illuminate\Support\Str;
13+
use ReflectionClass;
1114
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Terminal;
1216

1317
class RouteListCommand extends Command
1418
{
@@ -50,11 +54,27 @@ class RouteListCommand extends Command
5054
protected $headers = ['Domain', 'Method', 'URI', 'Name', 'Action', 'Middleware'];
5155

5256
/**
53-
* The columns to display when using the "compact" flag.
57+
* The terminal width resolver callback.
5458
*
55-
* @var string[]
59+
* @var \Closure|null
5660
*/
57-
protected $compactColumns = ['method', 'uri', 'action'];
61+
protected static $terminalWidthResolver;
62+
63+
/**
64+
* The verb colors for the command.
65+
*
66+
* @var array
67+
*/
68+
protected $verbColors = [
69+
'ANY' => 'red',
70+
'GET' => 'blue',
71+
'HEAD' => '#6C7280',
72+
'OPTIONS' => '#6C7280',
73+
'POST' => 'yellow',
74+
'PUT' => 'yellow',
75+
'PATCH' => 'yellow',
76+
'DELETE' => 'red',
77+
];
5878

5979
/**
6080
* Create a new route command instance.
@@ -164,13 +184,11 @@ protected function pluckColumns(array $routes)
164184
*/
165185
protected function displayRoutes(array $routes)
166186
{
167-
if ($this->option('json')) {
168-
$this->line($this->asJson($routes));
169-
170-
return;
171-
}
187+
$routes = collect($routes);
172188

173-
$this->table($this->getHeaders(), $routes);
189+
$this->output->writeln(
190+
$this->option('json') ? $this->asJson($routes) : $this->forCli($routes)
191+
);
174192
}
175193

176194
/**
@@ -195,8 +213,8 @@ protected function getMiddleware($route)
195213
protected function filterRoute(array $route)
196214
{
197215
if (($this->option('name') && ! Str::contains($route['name'], $this->option('name'))) ||
198-
$this->option('path') && ! Str::contains($route['uri'], $this->option('path')) ||
199-
$this->option('method') && ! Str::contains($route['method'], strtoupper($this->option('method')))) {
216+
$this->option('path') && ! Str::contains($route['uri'], $this->option('path')) ||
217+
$this->option('method') && ! Str::contains($route['method'], strtoupper($this->option('method')))) {
200218
return;
201219
}
202220

@@ -228,17 +246,7 @@ protected function getHeaders()
228246
*/
229247
protected function getColumns()
230248
{
231-
$availableColumns = array_map('strtolower', $this->headers);
232-
233-
if ($this->option('compact')) {
234-
return array_intersect($availableColumns, $this->compactColumns);
235-
}
236-
237-
if ($columns = $this->option('columns')) {
238-
return array_intersect($availableColumns, $this->parseColumns($columns));
239-
}
240-
241-
return $availableColumns;
249+
return array_map('strtolower', $this->headers);
242250
}
243251

244252
/**
@@ -265,12 +273,12 @@ protected function parseColumns(array $columns)
265273
/**
266274
* Convert the given routes to JSON.
267275
*
268-
* @param array $routes
276+
* @param \Illuminate\Support\Collection $routes
269277
* @return string
270278
*/
271-
protected function asJson(array $routes)
279+
protected function asJson($routes)
272280
{
273-
return collect($routes)
281+
return $routes
274282
->map(function ($route) {
275283
$route['middleware'] = empty($route['middleware']) ? [] : explode("\n", $route['middleware']);
276284

@@ -280,6 +288,125 @@ protected function asJson(array $routes)
280288
->toJson();
281289
}
282290

291+
/**
292+
* Convert the given routes to regular CLI output.
293+
*
294+
* @param \Illuminate\Support\Collection $routes
295+
* @return array
296+
*/
297+
protected function forCli($routes)
298+
{
299+
$routes = $routes->map(
300+
fn ($route) => array_merge($route, [
301+
'action' => $this->formatActionForCli($route),
302+
'method' => $route['method'] == 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS' ? 'ANY' : $route['method'],
303+
'uri' => $route['domain'] ? ($route['domain'].'/'.$route['uri']) : $route['uri'],
304+
]),
305+
);
306+
307+
$maxMethod = mb_strlen($routes->max('method'));
308+
309+
$terminalWidth = $this->getTerminalWidth();
310+
311+
return $routes->map(function ($route) use ($maxMethod, $terminalWidth) {
312+
[
313+
'action' => $action,
314+
'domain' => $domain,
315+
'method' => $method,
316+
'middleware' => $middleware,
317+
'uri' => $uri,
318+
] = $route;
319+
320+
$middleware = Str::of($middleware)->explode("\n")->filter()->whenNotEmpty(
321+
fn ($collection) => $collection->map(
322+
fn ($middleware) => sprintf(' %s⇂ %s', str_repeat(' ', $maxMethod), $middleware)
323+
)
324+
)->implode("\n");
325+
326+
$spaces = str_repeat(' ', max($maxMethod + 6 - mb_strlen($method), 0));
327+
328+
$dots = str_repeat('.', max(
329+
$terminalWidth - mb_strlen($method.$spaces.$uri.$action) - 6 - ($action ? 1 : 0), 0
330+
));
331+
332+
$dots = empty($dots) ? $dots : " $dots";
333+
334+
if ($action && ! $this->output->isVerbose() && mb_strlen($method.$spaces.$uri.$action.$dots) > ($terminalWidth - 6)) {
335+
$action = substr($action, 0, $terminalWidth - 7 - mb_strlen($method.$spaces.$uri.$dots)).'';
336+
}
337+
338+
$method = Str::of($method)->explode('|')->map(
339+
fn ($method) => sprintf('<fg=%s>%s</>', $this->verbColors[$method] ?? 'default', $method),
340+
)->implode('<fg=#6C7280>|</>');
341+
342+
return [sprintf(
343+
' <fg=white;options=bold>%s</> %s<fg=white>%s</><fg=#6C7280>%s %s</>',
344+
$method,
345+
$spaces,
346+
preg_replace('#({[^}]+})#', '<fg=yellow>$1</>', $uri),
347+
$dots,
348+
str_replace(' ', '', $action),
349+
), $this->output->isVerbose() && ! empty($middleware) ? "<fg=#6C7280>$middleware</>" : null];
350+
})->flatten()->filter()->prepend('')->push('')->toArray();
351+
}
352+
353+
/**
354+
* Get the formatted action for display on the CLI.
355+
*
356+
* @param array $route
357+
* @return string
358+
*/
359+
protected function formatActionForCli($route)
360+
{
361+
['action' => $action, 'name' => $name] = $route;
362+
363+
if ($action === 'Closure' || $action === ViewController::class) {
364+
return $name;
365+
}
366+
367+
$name = $name ? "$name " : null;
368+
369+
$rootControllerNamespace = $this->laravel[UrlGenerator::class]->getRootControllerNamespace()
370+
?? ($this->laravel->getNamespace().'Http\\Controllers');
371+
372+
if (str_starts_with($action, $rootControllerNamespace)) {
373+
return $name.substr($action, mb_strlen($rootControllerNamespace) + 1);
374+
}
375+
376+
$actionClass = explode('@', $action)[0];
377+
378+
if (class_exists($actionClass) && str_starts_with((new ReflectionClass($actionClass))->getFilename(), base_path('vendor'))) {
379+
$actionCollection = collect(explode('\\', $action));
380+
381+
return $name.$actionCollection->take(2)->implode('\\').' '.$actionCollection->last();
382+
}
383+
384+
return $name.$action;
385+
}
386+
387+
/**
388+
* Get the terminal width.
389+
*
390+
* @return int
391+
*/
392+
public static function getTerminalWidth()
393+
{
394+
return is_null(static::$terminalWidthResolver)
395+
? (new Terminal)->getWidth()
396+
: call_user_func(static::$terminalWidthResolver);
397+
}
398+
399+
/**
400+
* Set a callback that should be used when resolving the terminal width.
401+
*
402+
* @param \Closure|null $callback
403+
* @return void
404+
*/
405+
public static function resolveTerminalWidthUsing($resolver)
406+
{
407+
static::$terminalWidthResolver = $resolver;
408+
}
409+
283410
/**
284411
* Get the console command options.
285412
*
@@ -288,8 +415,6 @@ protected function asJson(array $routes)
288415
protected function getOptions()
289416
{
290417
return [
291-
['columns', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Columns to include in the route table'],
292-
['compact', 'c', InputOption::VALUE_NONE, 'Only show method, URI and action columns'],
293418
['json', null, InputOption::VALUE_NONE, 'Output the route list as JSON'],
294419
['method', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by method'],
295420
['name', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by name'],

src/Illuminate/Routing/UrlGenerator.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,16 @@ public function setKeyResolver(callable $keyResolver)
793793
return $this;
794794
}
795795

796+
/**
797+
* Get the root controller namespace.
798+
*
799+
* @return string
800+
*/
801+
public function getRootControllerNamespace()
802+
{
803+
return $this->rootNamespace;
804+
}
805+
796806
/**
797807
* Set the root controller namespace.
798808
*
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Testing;
4+
5+
use Illuminate\Contracts\Routing\Registrar;
6+
use Illuminate\Foundation\Auth\User;
7+
use Illuminate\Foundation\Console\RouteListCommand;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Routing\Controller;
10+
use Illuminate\Support\Facades\Facade;
11+
use Orchestra\Testbench\TestCase;
12+
13+
class RouteListCommandTest extends TestCase
14+
{
15+
/**
16+
* @var \Illuminate\Contracts\Routing\Registrar
17+
*/
18+
private $router;
19+
20+
/**
21+
* @var \Illuminate\Routing\UrlGenerator
22+
*/
23+
private $urlGenerator;
24+
25+
protected function setUp(): void
26+
{
27+
parent::setUp();
28+
29+
$this->router = $this->app->make(Registrar::class);
30+
31+
RouteListCommand::resolveTerminalWidthUsing(function () {
32+
return 70;
33+
});
34+
}
35+
36+
public function testDisplayRoutesForCli()
37+
{
38+
$this->router->get('closure', function () {
39+
return new RedirectResponse($this->urlGenerator->signedRoute('signed-route'));
40+
});
41+
42+
$this->router->get('controller-method/{user}', [FooController::class, 'show']);
43+
$this->router->post('controller-invokable', FooController::class);
44+
$this->router->domain('{account}.example.com')->group(function () {
45+
$this->router->get('user/{id}', function ($account, $id) {
46+
//
47+
})->name('user.show')->middleware('web');
48+
});
49+
50+
$this->artisan(RouteListCommand::class)
51+
->assertSuccessful()
52+
->expectsOutput('')
53+
->expectsOutput(' GET|HEAD closure ............................................... ')
54+
->expectsOutput(' POST controller-invokable Illuminate\Tests\Testing\FooContr…')
55+
->expectsOutput(' GET|HEAD controller-method/{user} Illuminate\Tests\Testing\FooC…')
56+
->expectsOutput(' GET|HEAD {account}.example.com/user/{id} ............. user.show')
57+
->expectsOutput('');
58+
}
59+
60+
public function testDisplayRoutesForCliInVerboseMode()
61+
{
62+
$this->router->get('closure', function () {
63+
return new RedirectResponse($this->urlGenerator->signedRoute('signed-route'));
64+
});
65+
66+
$this->router->get('controller-method/{user}', [FooController::class, 'show']);
67+
$this->router->post('controller-invokable', FooController::class);
68+
$this->router->domain('{account}.example.com')->group(function () {
69+
$this->router->get('user/{id}', function ($account, $id) {
70+
//
71+
})->name('user.show')->middleware('web');
72+
});
73+
74+
$this->artisan(RouteListCommand::class, ['-v' => true])
75+
->assertSuccessful()
76+
->expectsOutput('')
77+
->expectsOutput(' GET|HEAD closure ............................................... ')
78+
->expectsOutput(' POST controller-invokable Illuminate\\Tests\\Testing\\FooController')
79+
->expectsOutput(' GET|HEAD controller-method/{user} Illuminate\\Tests\\Testing\\FooController@show')
80+
->expectsOutput(' GET|HEAD {account}.example.com/user/{id} ............. user.show')
81+
->expectsOutput(' ⇂ web')
82+
->expectsOutput('');
83+
}
84+
85+
public function tearDown(): void
86+
{
87+
parent::tearDown();
88+
89+
Facade::setFacadeApplication(null);
90+
91+
RouteListCommand::resolveTerminalWidthUsing(null);
92+
}
93+
}
94+
95+
class FooController extends Controller
96+
{
97+
public function show(User $user)
98+
{
99+
// ..
100+
}
101+
102+
public function __invoke()
103+
{
104+
// ..
105+
}
106+
}

0 commit comments

Comments
 (0)