Passed
Push — master ( a26e64...9c0b7f )
by Alexander
25:13 queued 22:49
created

Serve   A

Complexity

Total Complexity 19

Size/Duplication

Total Lines 150
Duplicated Lines 0 %

Test Coverage

Coverage 90.91%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 19
eloc 70
c 4
b 0
f 0
dl 0
loc 150
ccs 60
cts 66
cp 0.9091
rs 10

6 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 1
A complete() 0 5 2
A configure() 0 10 1
A getRootPath() 0 7 2
A isAddressTaken() 0 11 2
B execute() 0 68 11
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\Yii\Console\ExitCode;
16
17
use function explode;
18
use function fclose;
19
use function file_exists;
20
use function fsockopen;
21
use function is_dir;
22
use function passthru;
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 string $defaultAddress;
31
    private string $defaultPort;
32
    private string $defaultDocroot;
33
    private string $defaultRouter;
34
    private int $defaultWorkers;
35
36
    protected static $defaultName = 'serve';
37
    protected static $defaultDescription = 'Runs PHP built-in web server';
38
39
    /**
40
     * @psalm-param array{
41
     *     address?:non-empty-string,
42
     *     port?:non-empty-string,
43
     *     docroot?:string,
44
     *     router?:string,
45
     *     workers?:int|string
46
     * } $options
47
     */
48 49
    public function __construct(private ?string $appRootPath = null, ?array $options = [])
49
    {
50 49
        $this->defaultAddress = $options['address'] ?? '127.0.0.1';
51 49
        $this->defaultPort = $options['port'] ?? '8080';
52 49
        $this->defaultDocroot = $options['docroot'] ?? 'public';
53 49
        $this->defaultRouter = $options['router'] ?? 'public/index.php';
54 49
        $this->defaultWorkers = (int) ($options['workers'] ?? 2);
55
56 49
        parent::__construct();
57
    }
58
59 49
    public function configure(): void
60
    {
61 49
        $this
62 49
            ->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.')
63 49
            ->addArgument('address', InputArgument::OPTIONAL, 'Host to serve at', $this->defaultAddress)
64 49
            ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to serve at', $this->defaultPort)
65 49
            ->addOption('docroot', 't', InputOption::VALUE_OPTIONAL, 'Document root to serve from', $this->defaultDocroot)
66 49
            ->addOption('router', 'r', InputOption::VALUE_OPTIONAL, 'Path to router script', $this->defaultRouter)
67 49
            ->addOption('workers', 'w', InputOption::VALUE_OPTIONAL, 'Workers number the server will start with', $this->defaultWorkers)
68 49
            ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.');
69
    }
70
71 1
    public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
72
    {
73 1
        if ($input->mustSuggestArgumentValuesFor('address')) {
74 1
            $suggestions->suggestValues(['localhost', '127.0.0.1', '0.0.0.0']);
75 1
            return;
76
        }
77
    }
78
79 6
    protected function execute(InputInterface $input, OutputInterface $output): int
80
    {
81 6
        $io = new SymfonyStyle($input, $output);
82
83
        /** @var string $address */
84 6
        $address = $input->getArgument('address');
85
86
        /** @var string $router */
87 6
        $router = $input->getOption('router');
88 6
        $workers = (int) $input->getOption('workers');
89
90
        /** @var string $port */
91 6
        $port = $input->getOption('port');
92
93
        /** @var string $docroot */
94 6
        $docroot = $input->getOption('docroot');
95
96 6
        if ($router === $this->defaultRouter && !file_exists($this->defaultRouter)) {
97 4
            $io->warning('Default router "' . $this->defaultRouter . '" does not exist. Serving without router. URLs with dots may fail.');
98 4
            $router = null;
99
        }
100
101
        /** @var string $env */
102 6
        $env = $input->getOption('env');
103
104 6
        $documentRoot = $this->getRootPath() . DIRECTORY_SEPARATOR . $docroot;
105
106 6
        if (!str_contains($address, ':')) {
107 5
            $address .= ':' . $port;
108
        }
109
110 6
        if (!is_dir($documentRoot)) {
111 1
            $io->error("Document root \"$documentRoot\" does not exist.");
112 1
            return self::EXIT_CODE_NO_DOCUMENT_ROOT;
113
        }
114
115 5
        if ($this->isAddressTaken($address)) {
116 1
            $io->error("http://$address is taken by another process.");
117 1
            return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
118
        }
119
120 4
        if ($router !== null && !file_exists($router)) {
121 1
            $io->error("Routing file \"$router\" does not exist.");
122 1
            return self::EXIT_CODE_NO_ROUTING_FILE;
123
        }
124
125 3
        $output->writeLn("Server started on <href=http://$address/>http://$address/</>");
126 3
        $output->writeLn("Document root is \"$documentRoot\"");
127
128 3
        if ($router) {
129 1
            $output->writeLn("Routing file is \"$router\"");
130
        }
131
132 3
        $output->writeLn('Quit the server with CTRL-C or COMMAND-C.');
133
134 3
        if ($env === 'test') {
135 3
            return ExitCode::OK;
136
        }
137
138
        $command = '"' . PHP_BINARY . '"' . " -S $address -t \"$documentRoot\" $router";
139
140
        if (DIRECTORY_SEPARATOR !== '\\') {
141
            $command = 'PHP_CLI_SERVER_WORKERS=' . $workers . ' ' . $command;
142
        }
143
144
        passthru($command);
145
146
        return ExitCode::OK;
147
    }
148
149
    /**
150
     * @param string $address The server address.
151
     *
152
     * @return bool If address is already in use.
153
     */
154 5
    private function isAddressTaken(string $address): bool
155
    {
156 5
        [$hostname, $port] = explode(':', $address);
157 5
        $fp = @fsockopen($hostname, (int)$port, $errno, $errstr, 3);
158
159 5
        if ($fp === false) {
160 4
            return false;
161
        }
162
163 1
        fclose($fp);
164 1
        return true;
165
    }
166
167 6
    private function getRootPath(): string
168
    {
169 6
        if ($this->appRootPath !== null) {
170
            return rtrim($this->appRootPath, DIRECTORY_SEPARATOR);
171
        }
172
173 6
        return getcwd();
174
    }
175
}
176