CheckDependencies::processNamespacePath()   B
last analyzed

Complexity

Conditions 7
Paths 9

Size

Total Lines 37
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 22
nc 9
nop 3
dl 0
loc 37
rs 8.6346
c 0
b 0
f 0
1
<?php
2
declare(strict_types = 1);
3
/**
4
 * /src/Command/Utils/CheckDependencies.php
5
 *
6
 * @author TLe, Tarmo Leppänen <[email protected]>
7
 */
8
9
namespace App\Command\Utils;
10
11
use App\Command\Traits\SymfonyStyleTrait;
12
use InvalidArgumentException;
13
use JsonException;
14
use LogicException;
15
use SplFileInfo;
16
use stdClass;
17
use Symfony\Component\Console\Attribute\AsCommand;
18
use Symfony\Component\Console\Command\Command;
19
use Symfony\Component\Console\Exception\RuntimeException;
20
use Symfony\Component\Console\Helper\ProgressBar;
21
use Symfony\Component\Console\Helper\Table;
22
use Symfony\Component\Console\Helper\TableSeparator;
23
use Symfony\Component\Console\Input\InputInterface;
24
use Symfony\Component\Console\Input\InputOption;
25
use Symfony\Component\Console\Output\OutputInterface;
26
use Symfony\Component\Console\Style\SymfonyStyle;
27
use Symfony\Component\DependencyInjection\Attribute\Autowire;
28
use Symfony\Component\Finder\Finder;
29
use Symfony\Component\Process\Process;
30
use Throwable;
31
use Traversable;
32
use function array_filter;
33
use function array_map;
34
use function array_unshift;
35
use function count;
36
use function dirname;
37
use function implode;
38
use function is_array;
39
use function iterator_to_array;
40
use function sort;
41
use function sprintf;
42
use function str_replace;
43
use function strlen;
44
use const DIRECTORY_SEPARATOR;
45
46
/**
47
 * Class CheckDependencies
48
 *
49
 * @package App\Command\Utils
50
 * @author TLe, Tarmo Leppänen <[email protected]>
51
 */
52
#[AsCommand(
53
    name: 'check-dependencies',
54
    description: 'Console command to check which vendor dependencies has updates',
55
)]
56
class CheckDependencies extends Command
57
{
58
    use SymfonyStyleTrait;
59
60
    public function __construct(
61
        #[Autowire('%kernel.project_dir%')]
62
        private readonly string $projectDir,
63
    ) {
64
        parent::__construct();
65
66
        $this->addOption(
67
            'minor',
68
            'm',
69
            InputOption::VALUE_NONE,
70
            'Only check for minor updates',
71
        );
72
73
        $this->addOption(
74
            'patch',
75
            'p',
76
            InputOption::VALUE_NONE,
77
            'Only check for patch updates',
78
        );
79
    }
80
81
    /**
82
     * @noinspection PhpMissingParentCallCommonInspection
83
     *
84
     * @throws Throwable
85
     */
86
    protected function execute(InputInterface $input, OutputInterface $output): int
87
    {
88
        $onlyMinor = $input->getOption('minor');
89
        $onlyPatch = $input->getOption('patch');
90
91
        $io = $this->getSymfonyStyle($input, $output);
92
        $io->info([
93
            'Starting to check dependencies...',
94
            match (true) {
95
                $onlyPatch => 'Checking only patch version updates',
96
                $onlyMinor => 'Checking only minor version updates',
97
                default => 'Checking for latest version updates',
98
            },
99
        ]);
100
101
        $directories = $this->getNamespaceDirectories();
102
103
        array_unshift($directories, $this->projectDir);
104
105
        $rows = $this->determineTableRows($io, $directories, $onlyMinor, $onlyPatch);
106
107
        $packageNameLength = max(
108
            array_map(
109
                static fn (array $row): int => isset($row[1]) ? strlen($row[1]) : 0,
110
                array_filter($rows, static fn (mixed $row): bool => !$row instanceof TableSeparator)
111
            ) + [0]
112
        );
113
114
        $style = clone Table::getStyleDefinition('box');
115
        $style->setCellHeaderFormat('<info>%s</info>');
116
117
        $table = new Table($output);
118
        $table->setHeaders($this->getHeaders());
119
        $table->setRows($rows);
120
        $table->setStyle($style);
121
122
        $this->setTableColumnWidths($packageNameLength, $table);
123
124
        $rows === []
125
            ? $io->success('Good news, there is no any vendor dependency to update at this time!')
126
            : $table->render();
127
128
        return 0;
129
    }
130
131
    /**
132
     * Method to determine all namespace directories under 'tools' directory.
133
     *
134
     * @return array<int, string>
135
     *
136
     * @throws LogicException
137
     * @throws InvalidArgumentException
138
     */
139
    private function getNamespaceDirectories(): array
140
    {
141
        // Find all main namespace directories under 'tools' directory
142
        $finder = (new Finder())
143
            ->depth(1)
144
            ->ignoreDotFiles(true)
145
            ->directories()
146
            ->in($this->projectDir . DIRECTORY_SEPARATOR . 'tools/');
147
148
        $closure = static fn (SplFileInfo $fileInfo): string => $fileInfo->getPath();
149
150
        /** @var Traversable<SplFileInfo> $iterator */
151
        $iterator = $finder->getIterator();
152
153
        // Determine namespace directories
154
        $directories = array_map($closure, iterator_to_array($iterator));
155
156
        sort($directories);
157
158
        return $directories;
159
    }
160
161
    /**
162
     * Method to determine table rows.
163
     *
164
     * @param array<int, string> $directories
165
     *
166
     * @psalm-return array<int, array<int, string>|TableSeparator>
167
     *
168
     * @throws JsonException
169
     */
170
    private function determineTableRows(SymfonyStyle $io, array $directories, bool $onlyMinor, bool $onlyPatch): array
171
    {
172
        // Initialize progress bar for process
173
        $progressBar = $this->getProgressBar($io, count($directories), 'Checking all vendor dependencies');
174
175
        // Initialize output rows
176
        $rows = [];
177
178
        $iterator = function (string $directory) use ($io, $onlyMinor, $onlyPatch, $progressBar, &$rows): void {
179
            foreach ($this->processNamespacePath($directory, $onlyMinor, $onlyPatch) as $row => $data) {
180
                $relativePath = '';
181
182
                // First row of current library
183
                if ($row === 0) {
184
                    // We want to add table separator between different libraries
185
                    if ($rows !== []) {
186
                        $rows[] = new TableSeparator();
187
                    }
188
189
                    $relativePath = str_replace($this->projectDir, '', $directory) . '/composer.json';
190
                } else {
191
                    $rows[] = [''];
192
                }
193
194
                $rows[] = $this->getPackageRow($relativePath, $data);
195
196
                if (isset($data->warning)) {
197
                    $rows[] = [''];
198
                    $rows[] = ['', '', '<fg=red>' . $data->warning . '</>'];
199
                }
200
201
                if (!property_exists($data, 'latest')) {
202
                    $rows[] = [''];
203
                    $rows[] = [
204
                        '',
205
                        '',
206
                        '<fg=yellow>There is newer version, but it\'s not compatible with current setup</>',
207
                    ];
208
                }
209
            }
210
211
            if (count($rows) === 1) {
212
                $io->write("\033\143");
213
            }
214
215
            $progressBar->advance();
216
        };
217
218
        array_map($iterator, $directories);
219
220
        return $rows;
221
    }
222
223
    /**
224
     * Method to process namespace inside 'tools' directory.
225
     *
226
     * @return array<int, stdClass>
227
     *
228
     * @throws JsonException
229
     */
230
    private function processNamespacePath(string $path, bool $onlyMinor, bool $onlyPatch): array
231
    {
232
        $command = [
233
            'composer',
234
            'outdated',
235
            '-D',
236
            '-f',
237
            'json',
238
        ];
239
240
        if ($onlyMinor) {
241
            $command[] = '-m';
242
        } elseif ($onlyPatch) {
243
            $command[] = '-p';
244
        }
245
246
        $process = new Process($command, $path);
247
        $process->enableOutput();
248
        $process->run();
249
250
        if ($process->getErrorOutput() !== '' && !($process->getExitCode() === 0 || $process->getExitCode() === null)) {
251
            $message = sprintf(
252
                "Running command '%s' failed with error message:\n%s",
253
                implode(' ', $command),
254
                $process->getErrorOutput()
255
            );
256
257
            throw new RuntimeException($message);
258
        }
259
260
        /** @var stdClass $decoded */
261
        $decoded = json_decode($process->getOutput(), flags: JSON_THROW_ON_ERROR);
262
263
        /** @var array<int, stdClass>|string|null $installed */
264
        $installed = $decoded->installed;
265
266
        return is_array($installed) ? $installed : [];
267
    }
268
269
    /**
270
     * Helper method to get progress bar for console.
271
     */
272
    private function getProgressBar(SymfonyStyle $io, int $steps, string $message): ProgressBar
273
    {
274
        $format = '
275
 %message%
276
 %current%/%max% [%bar%] %percent:3s%%
277
 Time elapsed:   %elapsed:-6s%
278
 Time remaining: %remaining:-6s%
279
 Time estimated: %estimated:-6s%
280
 Memory usage:   %memory:-6s%
281
';
282
283
        $progress = $io->createProgressBar($steps);
284
        $progress->setFormat($format);
285
        $progress->setMessage($message);
286
287
        return $progress;
288
    }
289
290
    /**
291
     * @return array<int, string>
292
     */
293
    private function getHeaders(): array
294
    {
295
        return [
296
            'Path',
297
            'Dependency',
298
            'Description',
299
            'Version',
300
            'New version',
301
        ];
302
    }
303
304
    /**
305
     * @return array{0: string, 1: string, 2: string, 3: string, 4: string}
306
     */
307
    private function getPackageRow(string $relativePath, mixed $data): array
308
    {
309
        return [
310
            dirname($relativePath),
311
            (string)$data->name,
312
            (string)$data->description,
313
            (string)$data->version,
314
            (string)(property_exists($data, 'latest') ? $data->latest : '<fg=yellow>' . $data->version . '</>'),
315
        ];
316
    }
317
318
    private function setTableColumnWidths(int $packageNameLength, Table $table): void
319
    {
320
        $widths = [
321
            23,
322
            $packageNameLength,
323
            95 - $packageNameLength,
324
            10,
325
            11,
326
        ];
327
328
        foreach ($widths as $columnIndex => $width) {
329
            $table->setColumnWidth($columnIndex, $width);
330
            $table->setColumnMaxWidth($columnIndex, $width);
331
        }
332
    }
333
}
334