Passed
Pull Request — master (#139)
by Rustam
02:07
created

Serve::findFreeAddress()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

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