Passed
Pull Request — master (#116)
by Théo
02:04
created

Compile::configureCompressionAlgorithm()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 18
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 18
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 13
nc 2
nop 4
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the box project.
7
 *
8
 * (c) Kevin Herrera <[email protected]>
9
 *     Théo Fidry <[email protected]>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14
15
namespace KevinGH\Box\Console\Command;
16
17
use Amp\MultiReasonException;
18
use Assert\Assertion;
19
use DateTimeImmutable;
20
use DateTimeZone;
21
use KevinGH\Box\Box;
22
use KevinGH\Box\Compactor;
23
use KevinGH\Box\Configuration;
24
use KevinGH\Box\Console\Logger\BuildLogger;
25
use KevinGH\Box\MapFile;
26
use KevinGH\Box\PhpSettingsHandler;
27
use KevinGH\Box\RequirementChecker\RequirementsDumper;
28
use KevinGH\Box\StubGenerator;
29
use RuntimeException;
30
use stdClass;
31
use Symfony\Component\Console\Helper\QuestionHelper;
32
use Symfony\Component\Console\Input\InputInterface;
33
use Symfony\Component\Console\Input\InputOption;
34
use Symfony\Component\Console\Logger\ConsoleLogger;
35
use Symfony\Component\Console\Output\OutputInterface;
36
use Symfony\Component\Console\Question\Question;
37
use Symfony\Component\Console\Style\SymfonyStyle;
38
use Symfony\Component\VarDumper\Cloner\VarCloner;
39
use Symfony\Component\VarDumper\Dumper\CliDumper;
40
use function count;
41
use function KevinGH\Box\disable_parallel_processing;
42
use function KevinGH\Box\FileSystem\chmod;
43
use function KevinGH\Box\FileSystem\dump_file;
44
use function KevinGH\Box\FileSystem\make_path_relative;
45
use function KevinGH\Box\FileSystem\remove;
46
use function KevinGH\Box\FileSystem\rename;
47
use function KevinGH\Box\formatted_filesize;
48
use function KevinGH\Box\get_phar_compression_algorithms;
49
use function putenv;
50
use const DATE_ATOM;
51
52
/**
53
 * @final
54
 * @private
55
 * TODO: make final when Build is removed
56
 */
57
class Compile extends Configurable
58
{
59
    use ChangeableWorkingDirectory;
60
61
    private const HELP = <<<'HELP'
62
The <info>%command.name%</info> command will compile code in a new PHAR based on a variety of settings.
63
<comment>
64
  This command relies on a configuration file for loading
65
  PHAR packaging settings. If a configuration file is not
66
  specified through the <info>--config|-c</info> option, one of
67
  the following files will be used (in order): <info>box.json</info>,
68
  <info>box.json.dist</info>
69
</comment>
70
The configuration file is actually a JSON object saved to a file. For more
71
information check the documentation online:
72
<comment>
73
  https://github.com/humbug/box
74
</comment>
75
HELP;
76
77
    private const DEBUG_OPTION = 'debug';
78
    private const NO_PARALLEL_PROCESSING_OPTION = 'no-parallel';
79
    private const NO_RESTART_OPTION = 'no-restart';
80
    private const DEV_OPTION = 'dev';
81
    private const NO_CONFIG_OPTION = 'no-config';
82
83
    private const DEBUG_DIR = '.box_dump';
84
85
    /**
86
     * {@inheritdoc}
87
     */
88
    protected function configure(): void
89
    {
90
        parent::configure();
91
92
        $this->setName('compile');
93
        $this->setDescription('Compile an application into a PHAR');
94
        $this->setHelp(self::HELP);
95
96
        $this->addOption(
97
            self::DEBUG_OPTION,
98
            null,
99
            InputOption::VALUE_NONE,
100
            'Dump the files added to the PHAR in a `'.self::DEBUG_DIR.'` directory'
101
        );
102
        $this->addOption(
103
            self::NO_PARALLEL_PROCESSING_OPTION,
104
            null,
105
            InputOption::VALUE_NONE,
106
            'Disable the parallel processing'
107
        );
108
        $this->addOption(
109
            self::NO_RESTART_OPTION,
110
            null,
111
            InputOption::VALUE_NONE,
112
            'Do not restart the PHP process. Box restarts the process by default to disable xdebug and set `phar.readonly=0`'
113
        );
114
        $this->addOption(
115
            self::DEV_OPTION,
116
            null,
117
            InputOption::VALUE_NONE,
118
            'Skips the compression step'
119
        );
120
        $this->addOption(
121
            self::NO_CONFIG_OPTION,
122
            null,
123
            InputOption::VALUE_NONE,
124
            'Ignore the config file even when one is specified with the --config option'
125
        );
126
127
        $this->configureWorkingDirOption();
128
    }
129
130
    /**
131
     * {@inheritdoc}
132
     */
133
    protected function execute(InputInterface $input, OutputInterface $output): void
134
    {
135
        $io = new SymfonyStyle($input, $output);
136
137
        if ($input->getOption(self::NO_RESTART_OPTION)) {
138
            putenv('BOX_ALLOW_XDEBUG=1');
139
        }
140
141
        if ($debug = $input->getOption(self::DEBUG_OPTION)) {
142
            $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
143
        }
144
145
        (new PhpSettingsHandler(new ConsoleLogger($output)))->check();
146
147
        if ($input->getOption(self::NO_PARALLEL_PROCESSING_OPTION)) {
148
            disable_parallel_processing();
149
            $io->writeln('<info>[debug] Disabled parallel processing</info>', OutputInterface::VERBOSITY_DEBUG);
150
        }
151
152
        $this->changeWorkingDirectory($input);
153
154
        $io->writeln($this->getApplication()->getHelp());
155
        $io->writeln('');
156
157
        $config = $input->getOption(self::NO_CONFIG_OPTION)
158
            ? Configuration::create(null, new stdClass())
159
            : $this->getConfig($input, $output, true)
160
        ;
161
        $path = $config->getOutputPath();
162
163
        $logger = new BuildLogger($io);
164
165
        $startTime = microtime(true);
166
167
        $this->removeExistingArtefacts($config, $logger, $debug);
168
169
        $logger->logStartBuilding($path);
170
171
        $this->createPhar($config, $input, $output, $logger, $io, $debug);
172
173
        $this->correctPermissions($path, $config, $logger);
174
175
        $logger->log(
176
            BuildLogger::STAR_PREFIX,
177
            'Done.'
178
        );
179
180
        $io->comment(
181
            sprintf(
182
                "<info>PHAR size: %s\nMemory usage: %.2fMB (peak: %.2fMB), time: %.2fs<info>",
183
                formatted_filesize($path),
184
                round(memory_get_usage() / 1024 / 1024, 2),
185
                round(memory_get_peak_usage() / 1024 / 1024, 2),
186
                round(microtime(true) - $startTime, 2)
187
            )
188
        );
189
    }
190
191
    private function createPhar(
192
        Configuration $config,
193
        InputInterface $input,
194
        OutputInterface $output,
195
        BuildLogger $logger,
196
        SymfonyStyle $io,
197
        bool $debug
198
    ): void {
199
        $box = Box::create(
200
            $config->getTmpOutputPath()
201
        );
202
        $box->getPhar()->startBuffering();
203
204
        $this->setReplacementValues($config, $box, $logger);
205
        $this->registerCompactors($config, $box, $logger);
206
        $this->registerFileMapping($config, $box, $logger);
207
208
        // Registering the main script _before_ adding the rest if of the files is _very_ important. The temporary
209
        // file used for debugging purposes and the Composer dump autoloading will not work correctly otherwise.
210
        $main = $this->registerMainScript($config, $box, $logger);
211
212
        $check = $this->registerRequirementsChecker($config, $box, $logger);
213
214
        $this->addFiles($config, $box, $logger, $io);
215
216
        $this->registerStub($config, $box, $main, $check, $logger);
217
        $this->configureMetadata($config, $box, $logger);
218
219
        $this->configureCompressionAlgorithm($config, $box, $input->getOption(self::DEV_OPTION), $logger);
220
221
        $box->getPhar()->stopBuffering();
222
223
        if ($debug) {
224
            $box->getPhar()->extractTo(self::DEBUG_DIR, null, true);
225
        }
226
227
        $this->signPhar($config, $box, $config->getTmpOutputPath(), $input, $output, $logger);
228
229
        if ($config->getTmpOutputPath() !== $config->getOutputPath()) {
230
            rename($config->getTmpOutputPath(), $config->getOutputPath());
231
        }
232
    }
233
234
    private function removeExistingArtefacts(Configuration $config, BuildLogger $logger, bool $debug): void
235
    {
236
        $path = $config->getOutputPath();
237
238
        if ($debug) {
239
            remove(self::DEBUG_DIR);
240
241
            $date = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM);
242
            $file = null !== $config->getFile() ? $config->getFile() : 'No config file';
243
244
            remove(self::DEBUG_DIR);
245
246
            dump_file(
247
                self::DEBUG_DIR.'/.box_configuration',
248
                <<<EOF
249
//
250
// Processed content of the configuration file "$file" dumped for debugging purposes
251
// Time: $date
252
//
253
254
255
EOF
256
                .(new CliDumper())->dump(
257
                    (new VarCloner())->cloneVar($config),
258
                    true
259
                )
260
            );
261
        }
262
263
        if (false === file_exists($path)) {
264
            return;
265
        }
266
267
        $logger->log(
268
            BuildLogger::QUESTION_MARK_PREFIX,
269
            sprintf(
270
                'Removing the existing PHAR "%s"',
271
                $path
272
            )
273
        );
274
275
        remove($path);
276
    }
277
278
    private function setReplacementValues(Configuration $config, Box $box, BuildLogger $logger): void
279
    {
280
        $values = $config->getProcessedReplacements();
281
282
        if ([] === $values) {
283
            return;
284
        }
285
286
        $logger->log(
287
            BuildLogger::QUESTION_MARK_PREFIX,
288
            'Setting replacement values'
289
        );
290
291
        foreach ($values as $key => $value) {
292
            $logger->log(
293
                BuildLogger::PLUS_PREFIX,
294
                sprintf(
295
                    '%s: %s',
296
                    $key,
297
                    $value
298
                )
299
            );
300
        }
301
302
        $box->registerPlaceholders($values);
303
    }
304
305
    private function registerCompactors(Configuration $config, Box $box, BuildLogger $logger): void
306
    {
307
        $compactors = $config->getCompactors();
308
309
        if ([] === $compactors) {
310
            $logger->log(
311
                BuildLogger::QUESTION_MARK_PREFIX,
312
                'No compactor to register'
313
            );
314
315
            return;
316
        }
317
318
        $logger->log(
319
            BuildLogger::QUESTION_MARK_PREFIX,
320
            'Registering compactors'
321
        );
322
323
        $logCompactors = function (Compactor $compactor) use ($logger): void {
324
            $logger->log(
325
                BuildLogger::PLUS_PREFIX,
326
                get_class($compactor)
327
            );
328
        };
329
330
        array_map($logCompactors, $compactors);
331
332
        $box->registerCompactors($compactors);
333
    }
334
335
    private function registerFileMapping(Configuration $config, Box $box, BuildLogger $logger): void
336
    {
337
        $fileMapper = $config->getFileMapper();
338
339
        $this->logMap($fileMapper, $logger);
340
341
        $box->registerFileMapping(
342
            $config->getBasePath(),
343
            $fileMapper
344
        );
345
    }
346
347
    private function addFiles(Configuration $config, Box $box, BuildLogger $logger, SymfonyStyle $io): void
348
    {
349
        $logger->log(BuildLogger::QUESTION_MARK_PREFIX, 'Adding binary files');
350
351
        $count = count($config->getBinaryFiles());
352
353
        $box->addFiles($config->getBinaryFiles(), true);
354
355
        $logger->log(
356
            BuildLogger::CHEVRON_PREFIX,
357
            0 === $count
358
                ? 'No file found'
359
                : sprintf('%d file(s)', $count)
360
        );
361
362
        $logger->log(BuildLogger::QUESTION_MARK_PREFIX, 'Adding files');
363
364
        $count = count($config->getFiles());
365
366
        try {
367
            $box->addFiles($config->getFiles(), false, null !== $config->getComposerJson());
368
        } catch (MultiReasonException $exception) {
369
            // This exception is handled a different way to give me meaningful feedback to the user
370
            foreach ($exception->getReasons() as $reason) {
371
                $io->error($reason);
372
            }
373
374
            throw $exception;
375
        }
376
377
        $logger->log(
378
            BuildLogger::CHEVRON_PREFIX,
379
            0 === $count
380
                ? 'No file found'
381
                : sprintf('%d file(s)', $count)
382
        );
383
    }
384
385
    private function registerMainScript(Configuration $config, Box $box, BuildLogger $logger): ?string
386
    {
387
        $main = $config->getMainScriptPath();
388
389
        $logger->log(
390
            BuildLogger::QUESTION_MARK_PREFIX,
391
            sprintf(
392
                'Adding main file: %s',
393
                $main
394
            )
395
        );
396
397
        $localMain = $box->addFile(
398
            $main,
399
            $config->getMainScriptContents()
400
        );
401
402
        $relativeMain = make_path_relative($main, $config->getBasePath());
403
404
        if ($localMain !== $relativeMain) {
405
            $logger->log(
406
                BuildLogger::CHEVRON_PREFIX,
407
                $localMain
408
            );
409
        }
410
411
        return $localMain;
412
    }
413
414
    private function registerRequirementsChecker(Configuration $config, Box $box, BuildLogger $logger): bool
415
    {
416
        if (false === $config->checkRequirements()) {
417
            return false;
418
        }
419
420
        $logger->log(
421
            BuildLogger::QUESTION_MARK_PREFIX,
422
            'Adding requirements checker'
423
        );
424
425
        $checkFiles = RequirementsDumper::dump($config->getComposerLockDecodedContents());
426
427
        foreach ($checkFiles as $fileWithContents) {
428
            [$file, $contents] = $fileWithContents;
429
430
            $box->addFile('.box/'.$file, $contents, true);
431
        }
432
433
        return true;
434
    }
435
436
    private function registerStub(Configuration $config, Box $box, string $main, bool $checkRequirements, BuildLogger $logger): void
437
    {
438
        if ($config->isStubGenerated()) {
439
            $logger->log(
440
                BuildLogger::QUESTION_MARK_PREFIX,
441
                'Generating new stub'
442
            );
443
444
            $stub = $this->createStub($config, $main, $checkRequirements, $logger);
445
446
            $box->getPhar()->setStub($stub->generate());
447
        } elseif (null !== ($stub = $config->getStubPath())) {
448
            $logger->log(
449
                BuildLogger::QUESTION_MARK_PREFIX,
450
                sprintf(
451
                    'Using stub file: %s',
452
                    $stub
453
                )
454
            );
455
456
            $box->registerStub($stub);
457
        } else {
458
            // TODO: add warning that the check requirements could not be added
459
            $aliasWasAdded = $box->getPhar()->setAlias($config->getAlias());
460
461
            Assertion::true(
462
                $aliasWasAdded,
463
                sprintf(
464
                    'The alias "%s" is invalid. See Phar::setAlias() documentation for more information.',
465
                    $config->getAlias()
466
                )
467
            );
468
469
            $box->getPhar()->setDefaultStub($main);
470
471
            $logger->log(
472
                BuildLogger::QUESTION_MARK_PREFIX,
473
                'Using default stub'
474
            );
475
        }
476
    }
477
478
    private function configureMetadata(Configuration $config, Box $box, BuildLogger $logger): void
479
    {
480
        if (null !== ($metadata = $config->getMetadata())) {
481
            $logger->log(
482
                BuildLogger::QUESTION_MARK_PREFIX,
483
                'Setting metadata'
484
            );
485
486
            $logger->log(
487
                BuildLogger::MINUS_PREFIX,
488
                is_string($metadata) ? $metadata : var_export($metadata, true)
489
            );
490
491
            $box->getPhar()->setMetadata($metadata);
492
        }
493
    }
494
495
    private function configureCompressionAlgorithm(Configuration $config, Box $box, bool $dev, BuildLogger $logger): void
496
    {
497
        if (null !== ($algorithm = $config->getCompressionAlgorithm())) {
498
            $logger->log(
499
                BuildLogger::QUESTION_MARK_PREFIX,
500
                sprintf(
501
                    'Compressing with the algorithm "<comment>%s</comment>"',
502
                    array_search($algorithm, get_phar_compression_algorithms(), true)
1 ignored issue
show
Bug introduced by
It seems like array_search($algorithm,...ion_algorithms(), true) can also be of type false; however, parameter $args of sprintf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

502
                    /** @scrutinizer ignore-type */ array_search($algorithm, get_phar_compression_algorithms(), true)
Loading history...
503
                )
504
            );
505
506
            $box->getPhar()->compressFiles($algorithm);
507
        } else {
508
            $logger->log(
509
                BuildLogger::QUESTION_MARK_PREFIX,
510
                $dev
511
                    ? 'No compression'
512
                    : '<error>No compression</error>'
513
            );
514
        }
515
    }
516
517
    private function signPhar(
518
        Configuration $config,
519
        Box $box,
520
        string $path,
521
        InputInterface $input,
522
        OutputInterface $output,
523
        BuildLogger $logger
524
    ): void {
525
        // sign using private key, if applicable
526
        //TODO: check that out
527
        remove($path.'.pubkey');
528
529
        $key = $config->getPrivateKeyPath();
530
531
        if (null === $key) {
532
            if (null !== ($algorithm = $config->getSigningAlgorithm())) {
0 ignored issues
show
introduced by
The condition null !== $algorithm = $c...->getSigningAlgorithm() is always true.
Loading history...
533
                $box->getPhar()->setSignatureAlgorithm($algorithm);
534
            }
535
536
            return;
537
        }
538
539
        $logger->log(
540
            BuildLogger::QUESTION_MARK_PREFIX,
541
            'Signing using a private key'
542
        );
543
544
        $passphrase = $config->getPrivateKeyPassphrase();
545
546
        if ($config->isPrivateKeyPrompt()) {
547
            if (false === $input->isInteractive()) {
548
                throw new RuntimeException(
549
                    sprintf(
550
                        'Accessing to the private key "%s" requires a passphrase but none provided. Either '
551
                        .'provide one or run this command in interactive mode.',
552
                        $key
553
                    )
554
                );
555
            }
556
557
            /** @var $dialog QuestionHelper */
558
            $dialog = $this->getHelper('question');
559
560
            $question = new Question('Private key passphrase:');
561
            $question->setHidden(false);
562
            $question->setHiddenFallback(false);
563
564
            $passphrase = $dialog->ask($input, $output, $question);
565
566
            $output->writeln('');
567
        }
568
569
        $box->signUsingFile($key, $passphrase);
570
    }
571
572
    private function correctPermissions(string $path, Configuration $config, BuildLogger $logger): void
573
    {
574
        if (null !== ($chmod = $config->getFileMode())) {
575
            $logger->log(
576
                BuildLogger::QUESTION_MARK_PREFIX,
577
                "Setting file permissions to <comment>$chmod</comment>"
578
            );
579
580
            chmod($path, $chmod);
581
        }
582
    }
583
584
    private function createStub(Configuration $config, ?string $main, bool $checkRequirements, BuildLogger $logger): StubGenerator
585
    {
586
        $stub = StubGenerator::create()
587
            ->alias($config->getAlias())
588
            ->index($main)
589
            ->intercept($config->isInterceptFileFuncs())
590
            ->checkRequirements($checkRequirements)
591
        ;
592
593
        if (null !== ($shebang = $config->getShebang())) {
594
            $logger->log(
595
                BuildLogger::MINUS_PREFIX,
596
                sprintf(
597
                    'Using shebang line: %s',
598
                    $shebang
599
                )
600
            );
601
602
            $stub->shebang($shebang);
603
        } else {
604
            $logger->log(
605
                BuildLogger::MINUS_PREFIX,
606
                'No shebang line'
607
            );
608
        }
609
610
        if (null !== ($bannerPath = $config->getStubBannerPath())) {
611
            $logger->log(
612
                BuildLogger::MINUS_PREFIX,
613
                sprintf(
614
                    'Using custom banner from file: %s',
615
                    $bannerPath
616
                )
617
            );
618
619
            $stub->banner($config->getStubBannerContents());
620
        } elseif (null !== ($banner = $config->getStubBannerContents())) {
621
            $logger->log(
622
                BuildLogger::MINUS_PREFIX,
623
                'Using banner:'
624
            );
625
626
            $bannerLines = explode("\n", $banner);
627
628
            foreach ($bannerLines as $bannerLine) {
629
                $logger->log(
630
                    BuildLogger::CHEVRON_PREFIX,
631
                    $bannerLine
632
                );
633
            }
634
635
            $stub->banner($banner);
636
        }
637
638
        return $stub;
639
    }
640
641
    private function logMap(MapFile $fileMapper, BuildLogger $logger): void
642
    {
643
        $map = $fileMapper->getMap();
644
645
        if ([] === $map) {
646
            return;
647
        }
648
649
        $logger->log(
650
            BuildLogger::QUESTION_MARK_PREFIX,
651
            'Mapping paths'
652
        );
653
654
        foreach ($map as $item) {
655
            foreach ($item as $match => $replace) {
656
                if ('' === $match) {
657
                    $match = '(all)';
658
                    $replace .= '/';
659
                }
660
661
                $logger->log(
662
                    BuildLogger::MINUS_PREFIX,
663
                    sprintf(
664
                        '%s <info>></info> %s',
665
                        $match,
666
                        $replace
667
                    )
668
                );
669
            }
670
        }
671
    }
672
}
673