Passed
Pull Request — master (#149)
by Rustam
02:29
created

Serve::complete()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 10
cc 2
nc 2
nop 2
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Console\Command;
6
7
use Symfony\Component\Console\Command\Command;
8
use Symfony\Component\Console\Completion\CompletionInput;
9
use Symfony\Component\Console\Completion\CompletionSuggestions;
10
use Symfony\Component\Console\Input\InputArgument;
11
use Symfony\Component\Console\Input\InputInterface;
12
use Symfony\Component\Console\Input\InputOption;
13
use Symfony\Component\Console\Output\OutputInterface;
14
use Symfony\Component\Console\Style\SymfonyStyle;
15
use Yiisoft\Aliases\Aliases;
16
use Yiisoft\Yii\Console\ExitCode;
17
18
use function explode;
19
use function fclose;
20
use function file_exists;
21
use function fsockopen;
22
use function is_dir;
23
use function passthru;
24
25
final class Serve extends Command
26
{
27
    public const EXIT_CODE_NO_DOCUMENT_ROOT = 2;
28
    public const EXIT_CODE_NO_ROUTING_FILE = 3;
29
    public const EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS = 5;
30
31
    private const DEFAULT_PORT = '8080';
32
    private const DEFAULT_DOCROOT = 'public';
33
    private const DEFAULT_ROUTER = 'public/index.php';
34
35
    protected static $defaultName = 'serve';
36
    protected static $defaultDescription = 'Runs PHP built-in web server';
37
38 45
    public function __construct(private ?string $appRootPath = null)
39
    {
40 45
        parent::__construct();
41
    }
42
43 45
    public function configure(): void
44
    {
45
        $this
46 45
            ->setHelp('In order to access server from remote machines use 0.0.0.0:8000. That is especially useful when running server in a virtual machine.')
47 45
            ->addArgument('address', InputArgument::OPTIONAL, 'Host to serve at', '127.0.0.1')
48 45
            ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to serve at', self::DEFAULT_PORT)
49 45
            ->addOption('docroot', 't', InputOption::VALUE_OPTIONAL, 'Document root to serve from', self::DEFAULT_DOCROOT)
50 45
            ->addOption('router', 'r', InputOption::VALUE_OPTIONAL, 'Path to router script', self::DEFAULT_ROUTER)
51 45
            ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.');
52
    }
53
54 1
    public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
55
    {
56 1
        if ($input->mustSuggestArgumentValuesFor('address')) {
57 1
            $suggestions->suggestValues(['localhost', '127.0.0.1', '0.0.0.0']);
58 1
            return;
59
        }
60
    }
61
62 5
    protected function execute(InputInterface $input, OutputInterface $output): int
63
    {
64 5
        $io = new SymfonyStyle($input, $output);
65
66
        /** @var string $address */
67 5
        $address = $input->getArgument('address');
68
69
        /** @var string $router */
70 5
        $router = $input->getOption('router');
71
72
        /** @var string $port */
73 5
        $port = $input->getOption('port');
74
75
        /** @var string $docroot */
76 5
        $docroot = $input->getOption('docroot');
77
78 5
        if ($router === self::DEFAULT_ROUTER && !file_exists(self::DEFAULT_ROUTER)) {
79 3
            $io->warning('Default router "' . self::DEFAULT_ROUTER . '" does not exist. Serving without router. URLs with dots may fail.');
80 3
            $router = null;
81
        }
82
83
        /** @var string $env */
84 5
        $env = $input->getOption('env');
85
86 5
        $documentRoot = $this->getRootPath() . DIRECTORY_SEPARATOR . $docroot;
87
88 5
        if (!str_contains($address, ':')) {
89 4
            $address .= ':' . $port;
90
        }
91
92 5
        if (!is_dir($documentRoot)) {
93 1
            $io->error("Document root \"$documentRoot\" does not exist.");
94 1
            return self::EXIT_CODE_NO_DOCUMENT_ROOT;
95
        }
96
97 4
        if ($this->isAddressTaken($address)) {
98 1
            $io->error("http://$address is taken by another process.");
99 1
            return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
100
        }
101
102 3
        if ($router !== null && !file_exists($router)) {
103 1
            $io->error("Routing file \"$router\" does not exist.");
104 1
            return self::EXIT_CODE_NO_ROUTING_FILE;
105
        }
106
107 2
        $output->writeLn("Server started on <href=http://$address/>http://$address/</>");
108 2
        $output->writeLn("Document root is \"$documentRoot\"");
109
110 2
        if ($router) {
111 1
            $output->writeLn("Routing file is \"$router\"");
112
        }
113
114 2
        $output->writeLn('Quit the server with CTRL-C or COMMAND-C.');
115
116 2
        if ($env === 'test') {
117 2
            return ExitCode::OK;
118
        }
119
120
        passthru('"' . PHP_BINARY . '"' . " -S $address -t \"$documentRoot\" $router");
121
122
        return ExitCode::OK;
123
    }
124
125
    /**
126
     * @param string $address The server address.
127
     *
128
     * @return bool If address is already in use.
129
     */
130 4
    private function isAddressTaken(string $address): bool
131
    {
132 4
        [$hostname, $port] = explode(':', $address);
133 4
        $fp = @fsockopen($hostname, (int)$port, $errno, $errstr, 3);
134
135 4
        if ($fp === false) {
136 3
            return false;
137
        }
138
139 1
        fclose($fp);
140 1
        return true;
141
    }
142
143 5
    private function getRootPath(): string
144
    {
145 5
        if ($this->appRootPath !== null) {
146
            return rtrim($this->appRootPath, DIRECTORY_SEPARATOR);
147
        }
148
149 5
        return getcwd();
150
    }
151
}
152