Skip to content

Commit

Permalink
[9.x] Redesign php artisan schedule:list Command. (#41445)
Browse files Browse the repository at this point in the history
* Redesign `php artisan schedule:list` Command.

* style: fix

* style: fix

* fix: Remove `SHELL_VERBOSITY` from env after tests.

* formatting

* empty satte

* adjust wording

* Clear `AliasLoader` after the tests.

* move test

* remove alias clear

* delete line

* fix: Replace " also (Windows)

* style: fixes

* Improve RegExp for windows machines.

* style: fixes

Co-authored-by: Taylor Otwell <taylor@laravel.com>
  • Loading branch information
xiCO2k and taylorotwell committed Mar 12, 2022
1 parent f691533 commit d13c348
Show file tree
Hide file tree
Showing 2 changed files with 185 additions and 18 deletions.
139 changes: 121 additions & 18 deletions src/Illuminate/Console/Scheduling/ScheduleListCommand.php
Expand Up @@ -4,8 +4,10 @@

use Cron\CronExpression;
use DateTimeZone;
use Illuminate\Console\Application;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Symfony\Component\Console\Terminal;

class ScheduleListCommand extends Command
{
Expand All @@ -23,6 +25,13 @@ class ScheduleListCommand extends Command
*/
protected $description = 'List the scheduled commands';

/**
* The terminal width resolver callback.
*
* @var \Closure|null
*/
protected static $terminalWidthResolver;

/**
* Execute the console command.
*
Expand All @@ -33,25 +42,119 @@ class ScheduleListCommand extends Command
*/
public function handle(Schedule $schedule)
{
foreach ($schedule->events() as $event) {
$rows[] = [
$event->command,
$event->expression,
$event->description,
(new CronExpression($event->expression))
->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
->format('Y-m-d H:i:s P'),
$event->mutex->exists($event) ? 'Yes' : '',
];
$events = collect($schedule->events());
$terminalWidth = $this->getTerminalWidth();
$expressionSpacing = $this->getCronExpressionSpacing($events);

$events = $events->map(function ($event) use ($terminalWidth, $expressionSpacing) {
$expression = $this->formatCronExpression($event->expression, $expressionSpacing);

$command = $event->command;

if (! $this->output->isVerbose()) {
$command = str_replace(
Application::artisanBinary(),
preg_replace("#['\"]#", '', Application::artisanBinary()),
str_replace(Application::phpBinary(), 'php', $event->command)
);
}

$command = mb_strlen($command) > 1 ? "{$command} " : '';

$nextDueDateLabel = 'Next Due:';

$nextDueDate = Carbon::create((new CronExpression($event->expression))
->getNextRunDate(Carbon::now()->setTimezone($event->timezone))
->setTimezone(new DateTimeZone($this->option('timezone') ?? config('app.timezone')))
);

$nextDueDate = $this->output->isVerbose()
? $nextDueDate->format('Y-m-d H:i:s P')
: $nextDueDate->diffForHumans();

$hasMutex = $event->mutex->exists($event) ? 'Has Mutex › ' : '';

$dots = str_repeat('.', max(
$terminalWidth - mb_strlen($expression.$command.$nextDueDateLabel.$nextDueDate.$hasMutex) - 8, 0
));

// Highlight the parameters...
$command = preg_replace("#(=['\"]?)([^'\"]+)(['\"]?)#", '$1<fg=yellow;options=bold>$2</>$3', $command);

return [sprintf(
' <fg=yellow>%s</> %s<fg=#6C7280>%s %s%s %s</>',
$expression,
$command,
$dots,
$hasMutex,
$nextDueDateLabel,
$nextDueDate
), $this->output->isVerbose() && mb_strlen($event->description) > 1 ? sprintf(
' <fg=#6C7280>%s%s %s</>',
str_repeat(' ', mb_strlen($expression) + 2),
'⇁',
$event->description
) : ''];
});

if ($events->isEmpty()) {
return $this->comment('No scheduled tasks have been defined.');
}

$this->table([
'Command',
'Interval',
'Description',
'Next Due',
'Has Mutex',
], $rows ?? []);
$this->output->writeln(
$events->flatten()->filter()->prepend('')->push('')->toArray()
);
}

/**
* Gets the spacing to be used on each event row.
*
* @param \Illuminate\Support\Collection $events
* @return array<int, int>
*/
private function getCronExpressionSpacing($events)
{
$rows = $events->map(fn ($event) => array_map('mb_strlen', explode(' ', $event->expression)));

return collect($rows[0] ?? [])->keys()->map(fn ($key) => $rows->max($key));
}

/**
* Formats the cron expression based on the spacing provided.
*
* @param string $expression
* @param array<int, int> $spacing
* @return string
*/
private function formatCronExpression($expression, $spacing)
{
$expression = explode(' ', $expression);

return collect($spacing)
->map(fn ($length, $index) => $expression[$index] = str_pad($expression[$index], $length))
->implode(' ');
}

/**
* Get the terminal width.
*
* @return int
*/
public static function getTerminalWidth()
{
return is_null(static::$terminalWidthResolver)
? (new Terminal)->getWidth()
: call_user_func(static::$terminalWidthResolver);
}

/**
* Set a callback that should be used when resolving the terminal width.
*
* @param \Closure|null $resolver
* @return void
*/
public static function resolveTerminalWidthUsing($resolver)
{
static::$terminalWidthResolver = $resolver;
}
}
64 changes: 64 additions & 0 deletions tests/Integration/Console/Scheduling/ScheduleListCommandTest.php
@@ -0,0 +1,64 @@
<?php

namespace Illuminate\Tests\Integration\Console\Scheduling;

use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\ScheduleListCommand;
use Illuminate\Support\Carbon;
use Illuminate\Support\ProcessUtils;
use Orchestra\Testbench\TestCase;

class ScheduleListCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

Carbon::setTestNow(now()->startOfYear());
ScheduleListCommand::resolveTerminalWidthUsing(fn () => 80);

$this->schedule = $this->app->make(Schedule::class);
}

public function testDisplaySchedule()
{
$this->schedule->call(fn () => '')->everyMinute();
$this->schedule->command(FooCommand::class)->quarterly();
$this->schedule->command('inspire')->twiceDaily(14, 18);
$this->schedule->command('foobar', ['a' => 'b'])->everyMinute();

$this->artisan(ScheduleListCommand::class)
->assertSuccessful()
->expectsOutput(' * * * * * ............................ Next Due: 1 minute from now')
->expectsOutput(' 0 0 1 1-12/3 * php artisan foo:command .... Next Due: 3 months from now')
->expectsOutput(' 0 14,18 * * * php artisan inspire ........ Next Due: 14 hours from now')
->expectsOutput(' * * * * * php artisan foobar a='.ProcessUtils::escapeArgument('b').' ... Next Due: 1 minute from now');
}

public function testDisplayScheduleInVerboseMode()
{
$this->schedule->command(FooCommand::class)->everyMinute();

$this->artisan(ScheduleListCommand::class, ['-v' => true])
->assertSuccessful()
->expectsOutputToContain('Next Due: '.now()->setMinutes(1)->format('Y-m-d H:i:s P'))
->expectsOutput(' ⇁ This is the description of the command.');
}

protected function tearDown(): void
{
parent::tearDown();

putenv('SHELL_VERBOSITY');

ScheduleListCommand::resolveTerminalWidthUsing(null);
}
}

class FooCommand extends Command
{
protected $signature = 'foo:command';

protected $description = 'This is the description of the command.';
}

0 comments on commit d13c348

Please sign in to comment.