diff --git a/src/Application.php b/src/Application.php index f60c7486..5430ac81 100644 --- a/src/Application.php +++ b/src/Application.php @@ -19,6 +19,7 @@ use Cake\Console\CommandCollection; use Cake\Core\ConsoleApplicationInterface; use Cake\Upgrade\Command\FileRenameCommand; +use Cake\Upgrade\Command\LinterCommand; use Cake\Upgrade\Command\RectorCommand; use Cake\Upgrade\Command\UpgradeCommand; @@ -51,6 +52,7 @@ public function console(CommandCollection $commands): CommandCollection $commands->add('upgrade', UpgradeCommand::class); $commands->add('upgrade file_rename', FileRenameCommand::class); $commands->add('upgrade rector', RectorCommand::class); + $commands->add('linter', LinterCommand::class); return $commands; } diff --git a/src/Command/Helper/ProgressHelper.php b/src/Command/Helper/ProgressHelper.php new file mode 100644 index 00000000..2c8b5ab6 --- /dev/null +++ b/src/Command/Helper/ProgressHelper.php @@ -0,0 +1,163 @@ +helper('Progress')->output(['callback' => function ($progress) { + * // Do work + * $progress->increment(); + * }); + * ``` + */ +class ProgressHelper extends Helper +{ + /** + * Default value for progress bar total value. + * Percent completion is derived from progress/total + */ + protected const DEFAULT_TOTAL = 100; + + /** + * Default value for progress bar width + */ + protected const DEFAULT_WIDTH = 80; + + /** + * The current progress. + * + * @var float|int + */ + protected float|int $_progress = 0; + + /** + * The total number of 'items' to progress through. + * + * @var int + */ + protected int $_total = self::DEFAULT_TOTAL; + + /** + * The width of the bar. + * + * @var int + */ + protected int $_width = self::DEFAULT_WIDTH; + + /** + * Output a progress bar. + * + * Takes a number of options to customize the behavior: + * + * - `total` The total number of items in the progress bar. Defaults + * to 100. + * - `width` The width of the progress bar. Defaults to 80. + * - `callback` The callback that will be called in a loop to advance the progress bar. + * + * @param array $args The arguments/options to use when outputting the progress bar. + * @return void + */ + public function output(array $args): void + { + $args += ['callback' => null]; + if (isset($args[0])) { + $args['callback'] = $args[0]; + } + if (!$args['callback'] || !is_callable($args['callback'])) { + throw new InvalidArgumentException('Callback option must be a callable.'); + } + $this->init($args); + + $callback = $args['callback']; + + $this->_io->out('', 0); + while ($this->_progress < $this->_total) { + $callback($this); + $this->draw(); + } + $this->_io->out(''); + } + + /** + * Initialize the progress bar for use. + * + * - `total` The total number of items in the progress bar. Defaults + * to 100. + * - `width` The width of the progress bar. Defaults to 80. + * + * @param array $args The initialization data. + * @return $this + */ + public function init(array $args = []) + { + $args += ['total' => self::DEFAULT_TOTAL, 'width' => self::DEFAULT_WIDTH]; + $this->_progress = 0; + $this->_width = $args['width']; + $this->_total = $args['total']; + + return $this; + } + + /** + * Increment the progress bar. + * + * @param float|int $num The amount of progress to advance by. + * @return $this + */ + public function increment(float|int $num = 1) + { + $this->_progress = min(max(0, $this->_progress + $num), $this->_total); + + return $this; + } + + /** + * Render the progress bar based on the current state. + * + * @return $this + */ + public function draw() + { + $numberLen = strlen(' 100%'); + $complete = round($this->_progress / $this->_total, 2); + $barLen = ($this->_width - $numberLen) * $this->_progress / $this->_total; + $bar = ''; + if ($barLen > 1) { + $bar = str_repeat('=', (int)$barLen - 1) . '>'; + } + + $pad = ceil($this->_width - $numberLen - $barLen); + if ($pad > 0) { + $bar .= str_repeat(' ', (int)$pad); + } + $percent = ($complete * 100) . '%'; + $bar .= str_pad($percent, $numberLen, ' ', STR_PAD_LEFT); + + $this->_io->overwrite($bar, 0); + + return $this; + } +} diff --git a/src/Command/LinterCommand.php b/src/Command/LinterCommand.php new file mode 100644 index 00000000..1463172e --- /dev/null +++ b/src/Command/LinterCommand.php @@ -0,0 +1,154 @@ +defaultDirectories() as $dir) { + $dirs[] = '`' . $dir . '`'; + } + + $help = 'The path (or comma separated paths) to the file or directory to check.'; + $help .= ' If not provided, defaults to checking common directories ' . implode(', ', $dirs) . '.'; + + return parent::buildOptionParser($parser) + ->addArgument('path', [ + 'help' => $help, + 'default' => null, + 'required' => false, + ]) + ->setDescription(static::getDescription()); + } + + /** + * Implement this method with your command's logic. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int The exit code + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + $directories = $args->getArgument('path') ?: $this->defaultDirectories(); + if (is_string($directories) && str_contains($directories, ',')) { + $directories = explode(',', $directories); + } + + $result = static::CODE_SUCCESS; + $errors = []; + foreach ((array)$directories as $directory) { + if (!file_exists($directory)) { + $io->warning('Does not exist: ' . $directory . ' - skipping.'); + + continue; + } + + $io->out('Checking ' . (is_file($directory) ? 'file' : 'directory') . ': ' . $directory); + if (is_file($directory)) { + $phpFiles = [ + new SplFileInfo($directory), + ]; + } else { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); + $phpFiles = new RegexIterator($files, '/\.php$/'); + } + + $fileCount = iterator_count($phpFiles); + $io->helper('Progress')->init(['total' => $fileCount]); + $io->out('', 0); + + /** @var \SplFileInfo $file */ + foreach ($phpFiles as $file) { + $io->verbose('Checking ' . $file->getPathname()); + + $command = 'php -l ' . $file->getPathname(); + $process = Process::fromShellCommandline($command); + $process->run(); + + if ($process->getExitCode() !== static::CODE_SUCCESS) { + $errors[] = $file->getPathname() . ': ' . $process->getErrorOutput(); + $result = self::CODE_ERROR; + + continue; + } + + $io->helper('Progress')->increment(); + $io->helper('Progress')->draw(); + } + + $io->out(''); + } + + if ($errors) { + $io->err($errors); + } + + if ($result === self::CODE_SUCCESS) { + $io->success('All files are valid.'); + } else { + $io->error('Some files have errors. Please check the output above.'); + } + + return $result; + } + + /** + * @return array + */ + protected function defaultDirectories(): array + { + $result = []; + foreach (static::$defaultDirectories as $directory) { + if (!is_dir($directory)) { + continue; + } + $result[] = $directory; + } + + return $result; + } +} diff --git a/tests/TestCase/Command/LinterCommandTest.php b/tests/TestCase/Command/LinterCommandTest.php new file mode 100644 index 00000000..91191f22 --- /dev/null +++ b/tests/TestCase/Command/LinterCommandTest.php @@ -0,0 +1,61 @@ +configApplication('\Cake\Upgrade\Application', []); + } + + /** + * @return void + */ + public function testLinterSuccess() + { + $this->exec('linter'); + + $this->assertExitSuccess(); + $this->assertOutputContains('All files are valid.'); + } + + /** + * @return void + */ + public function testLinterFail() + { + mkdir(TMP . 'linter', 0777, true); + file_put_contents(TMP . 'linter/BrokenExample.php', 'exec('linter tmp/linter/ -v'); + + $this->assertExitError(); + $this->assertErrorContains('Some files have errors.'); + } +}