Serve   B
last analyzed

Complexity

Total Complexity 50

Size/Duplication

Total Lines 338
Duplicated Lines 0 %

Importance

Changes 12
Bugs 3 Features 1
Metric Value
eloc 191
dl 0
loc 338
rs 8.4
c 12
b 3
f 1
wmc 50

6 Methods

Rating   Name   Duplication   Size   Complexity  
F execute() 0 185 33
A configure() 0 22 1
A setupWatcher() 0 10 3
A buildSuccessActions() 0 15 5
A setUpServer() 0 28 6
A tearDownServer() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like Serve often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Serve, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Command;
15
16
use Cecil\Exception\RuntimeException;
17
use Cecil\Util;
18
use Joli\JoliNotif\DefaultNotifier;
19
use Symfony\Component\Console\Input\InputArgument;
20
use Symfony\Component\Console\Input\InputInterface;
21
use Symfony\Component\Console\Input\InputOption;
22
use Symfony\Component\Console\Output\OutputInterface;
23
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
24
use Symfony\Component\Finder\Finder;
25
use Symfony\Component\Process\Exception\ProcessFailedException;
26
use Symfony\Component\Process\PhpExecutableFinder;
27
use Symfony\Component\Process\Process;
28
use Yosymfony\ResourceWatcher\Crc32ContentHash;
29
use Yosymfony\ResourceWatcher\ResourceCacheMemory;
30
use Yosymfony\ResourceWatcher\ResourceWatcher;
31
32
/**
33
 * Serve command.
34
 *
35
 * This command starts the built-in web server with live reloading capabilities.
36
 * It allows users to serve their website locally and automatically rebuild it when changes are detected.
37
 * It also supports opening the web browser automatically and includes options for drafts, optimization, and more.
38
 */
39
class Serve extends AbstractCommand
40
{
41
    /** @var boolean */
42
    protected $watcherEnabled;
43
44
    /**
45
     * {@inheritdoc}
46
     */
47
    protected function configure()
48
    {
49
        $this
50
            ->setName('serve')
51
            ->setDescription('Starts the built-in server')
52
            ->setDefinition([
53
                new InputArgument('path', InputArgument::OPTIONAL, 'Use the given path as working directory'),
54
                new InputOption('open', 'o', InputOption::VALUE_NONE, 'Open web browser automatically'),
55
                new InputOption('host', null, InputOption::VALUE_REQUIRED, 'Server host', 'localhost'),
56
                new InputOption('port', null, InputOption::VALUE_REQUIRED, 'Server port', '8000'),
57
                new InputOption('watch', 'w', InputOption::VALUE_NEGATABLE, 'Enable (or disable --no-watch) changes watcher (enabled by default)', true),
58
                new InputOption('drafts', 'd', InputOption::VALUE_NONE, 'Include drafts'),
59
                new InputOption('optimize', null, InputOption::VALUE_NEGATABLE, 'Enable (or disable --no-optimize) optimization of generated files'),
60
                new InputOption('config', 'c', InputOption::VALUE_REQUIRED, 'Set the path to extra config files (comma-separated)'),
61
                new InputOption('clear-cache', null, InputOption::VALUE_OPTIONAL, 'Clear cache before build (optional cache key as regular expression)', false),
62
                new InputOption('page', 'p', InputOption::VALUE_REQUIRED, 'Build a specific page'),
63
                new InputOption('no-ignore-vcs', null, InputOption::VALUE_NONE, 'Changes watcher must not ignore VCS directories'),
64
                new InputOption('metrics', 'm', InputOption::VALUE_NONE, 'Show build metrics (duration and memory) of each step'),
65
                new InputOption('timeout', null, InputOption::VALUE_REQUIRED, 'Sets the process timeout (max. runtime) in seconds', 7200), // default is 2 hours
66
            ])
67
            ->setHelp(
68
                <<<'EOF'
69
The <info>%command.name%</> command starts the live-reloading-built-in web server.
70
71
  <info>%command.full_name%</>
72
  <info>%command.full_name% path/to/the/working/directory</>
73
  <info>%command.full_name% --open</>
74
  <info>%command.full_name% --drafts</>
75
  <info>%command.full_name% --no-watch</>
76
77
You can use a custom host and port by using the <info>--host</info> and <info>--port</info> options:
78
79
  <info>%command.full_name% --host=127.0.0.1 --port=8080</>
80
81
To build the website with an extra configuration file, you can use the <info>--config</info> option.
82
This is useful during local development to <comment>override some settings</comment> without modifying the main configuration:
83
84
  <info>%command.full_name% --config=config/dev.yml</>
85
86
To start the server with changes watcher <comment>not ignoring VCS</comment> directories, run:
87
88
  <info>%command.full_name% --no-ignore-vcs</>
89
90
To define the process <comment>timeout</comment> (in seconds), run:
91
92
  <info>%command.full_name% --timeout=7200</>
93
EOF
94
            );
95
    }
96
97
    /**
98
     * {@inheritdoc}
99
     *
100
     * @throws RuntimeException
101
     */
102
    protected function execute(InputInterface $input, OutputInterface $output): int
103
    {
104
        $open = $input->getOption('open');
105
        $host = $input->getOption('host');
106
        $port = $input->getOption('port');
107
        $drafts = $input->getOption('drafts');
108
        $optimize = $input->getOption('optimize');
109
        $clearcache = $input->getOption('clear-cache');
110
        $page = $input->getOption('page');
111
        $noignorevcs = $input->getOption('no-ignore-vcs');
112
        $metrics = $input->getOption('metrics');
113
        $timeout = $input->getOption('timeout');
114
        $verbose = $input->getOption('verbose');
115
116
        $resourceWatcher = null;
117
        $this->watcherEnabled = $input->getOption('watch');
118
119
        // checks if PHP executable is available
120
        $phpFinder = new PhpExecutableFinder();
121
        $php = $phpFinder->find();
122
        if ($php === false) {
123
            throw new RuntimeException('Can\'t find a local PHP executable.');
124
        }
125
126
        // setup server
127
        $this->setUpServer();
128
        $command = \sprintf(
129
            '"%s" -S %s:%d -t "%s" "%s"',
130
            $php,
131
            $host,
132
            $port,
133
            Util::joinFile($this->getPath(), self::SERVE_OUTPUT),
134
            Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php')
135
        );
136
        $process = Process::fromShellCommandline($command);
137
138
        // setup build process
139
        $buildProcessArguments = [
140
            $php,
141
            $_SERVER['argv'][0],
142
        ];
143
        $buildProcessArguments[] = 'build';
144
        $buildProcessArguments[] = $this->getPath();
145
        if (!empty($this->getConfigFiles())) {
146
            $buildProcessArguments[] = '--config';
147
            $buildProcessArguments[] = implode(',', $this->getConfigFiles());
148
        }
149
        if ($drafts) {
150
            $buildProcessArguments[] = '--drafts';
151
        }
152
        if ($optimize === true) {
153
            $buildProcessArguments[] = '--optimize';
154
        }
155
        if ($optimize === false) {
156
            $buildProcessArguments[] = '--no-optimize';
157
        }
158
        if ($clearcache === null) {
159
            $buildProcessArguments[] = '--clear-cache';
160
        }
161
        if (!empty($clearcache)) {
162
            $buildProcessArguments[] = '--clear-cache';
163
            $buildProcessArguments[] = $clearcache;
164
        }
165
        if ($verbose) {
166
            $buildProcessArguments[] = '-' . str_repeat('v', $_SERVER['SHELL_VERBOSITY']);
167
        }
168
        if (!empty($page)) {
169
            $buildProcessArguments[] = '--page';
170
            $buildProcessArguments[] = $page;
171
        }
172
        if (!empty($metrics)) {
173
            $buildProcessArguments[] = '--metrics';
174
        }
175
        $buildProcessArguments[] = '--baseurl';
176
        $buildProcessArguments[] = "http://$host:$port/";
177
        $buildProcessArguments[] = '--output';
178
        $buildProcessArguments[] = self::SERVE_OUTPUT;
179
        $buildProcess = new Process(
180
            $buildProcessArguments,
181
            null,
182
            ['BOX_REQUIREMENT_CHECKER' => '0'] // prevents double check (build then serve)
183
        );
184
        $buildProcess->setTty(Process::isTtySupported());
185
        $buildProcess->setPty(Process::isPtySupported());
186
        $buildProcess->setTimeout((float) $timeout);
187
        $processOutputCallback = function ($type, $buffer) use ($output) {
188
            $output->write($buffer, false, OutputInterface::OUTPUT_RAW);
189
        };
190
191
        // builds before serve
192
        $output->writeln(\sprintf('<comment>Build process: %s</comment>', implode(' ', $buildProcessArguments)), OutputInterface::VERBOSITY_DEBUG);
193
        $buildProcess->run($processOutputCallback);
194
        if ($buildProcess->isSuccessful()) {
195
            $this->buildSuccessActions($output);
196
        }
197
        if ($buildProcess->getExitCode() !== 0) {
198
            $this->tearDownServer();
199
200
            return 1;
201
        }
202
203
        // handles serve process
204
        if (!$process->isStarted()) {
205
            $messageSuffix = '';
206
            // setup resource watcher
207
            if ($this->watcherEnabled) {
208
                $resourceWatcher = $this->setupWatcher($noignorevcs);
209
                $resourceWatcher->initialize();
210
                $messageSuffix = ' with changes watcher';
211
            }
212
            // starts server
213
            try {
214
                if (\function_exists('\pcntl_signal')) {
215
                    pcntl_async_signals(true);
216
                    pcntl_signal(SIGINT, [$this, 'tearDownServer']);
217
                    pcntl_signal(SIGTERM, [$this, 'tearDownServer']);
218
                }
219
                $output->writeln(\sprintf('<comment>Server process: %s</comment>', $command), OutputInterface::VERBOSITY_DEBUG);
220
                $output->writeln(\sprintf('Starting server (<href=http://%s:%d>http://%s:%d</>)%s...', $host, $port, $host, $port, $messageSuffix));
221
                $process->start(function ($type, $buffer) {
222
                    if ($type === Process::ERR) {
223
                        error_log($buffer, 3, Util::joinFile($this->getPath(), self::TMP_DIR, 'errors.log'));
224
                    }
225
                });
226
                // notification
227
                if (self::DESKTOP_NOTIFICATION) {
228
                    $notifier = new DefaultNotifier();
229
                    $this->notification->setBody('Starting server');
230
                    $notifier->send($this->notification);
231
                }
232
                if ($open) {
233
                    $output->writeln('Opening web browser...');
234
                    Util\Platform::openBrowser(\sprintf('http://%s:%s', $host, $port));
235
                }
236
                while ($process->isRunning()) {
237
                    sleep(1); // wait for server is ready
238
                    if (!fsockopen($host, (int) $port)) {
239
                        $output->writeln('<info>Server is not ready.</info>');
240
241
                        return 1;
242
                    }
243
                    if ($this->watcherEnabled && $resourceWatcher instanceof ResourceWatcher) {
244
                        $watcher = $resourceWatcher->findChanges();
245
                        if ($watcher->hasChanges()) {
246
                            $output->writeln('<comment>Changes detected.</comment>');
247
                            // prints deleted/new/updated files in debug mode
248
                            if (\count($watcher->getDeletedFiles()) > 0) {
249
                                $output->writeln('<comment>Deleted files:</comment>', OutputInterface::VERBOSITY_DEBUG);
250
                                foreach ($watcher->getDeletedFiles() as $file) {
251
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
252
                                }
253
                            }
254
                            if (\count($watcher->getNewFiles()) > 0) {
255
                                $output->writeln('<comment>New files:</comment>', OutputInterface::VERBOSITY_DEBUG);
256
                                foreach ($watcher->getNewFiles() as $file) {
257
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
258
                                }
259
                            }
260
                            if (\count($watcher->getUpdatedFiles()) > 0) {
261
                                $output->writeln('<comment>Updated files:</comment>', OutputInterface::VERBOSITY_DEBUG);
262
                                foreach ($watcher->getUpdatedFiles() as $file) {
263
                                    $output->writeln("<comment>- $file</comment>", OutputInterface::VERBOSITY_DEBUG);
264
                                }
265
                            }
266
                            $output->writeln('');
267
                            // re-builds
268
                            $buildProcess->run($processOutputCallback);
269
                            if ($buildProcess->isSuccessful()) {
270
                                $this->buildSuccessActions($output);
271
                            }
272
                            $output->writeln('<info>Server is runnning...</info>');
273
                        }
274
                    }
275
                }
276
                if ($process->getExitCode() > 0) {
277
                    $output->writeln(\sprintf('<comment>%s</comment>', trim($process->getErrorOutput())));
278
                }
279
            } catch (ProcessFailedException $e) {
280
                $this->tearDownServer();
281
282
                throw new RuntimeException(\sprintf($e->getMessage()));
283
            }
284
        }
285
286
        return 0;
287
    }
288
289
    /**
290
     * Build success actions.
291
     */
292
    private function buildSuccessActions(OutputInterface $output): void
293
    {
294
        // writes `changes.flag` file
295
        if ($this->watcherEnabled) {
296
            Util\File::getFS()->dumpFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'changes.flag'), time());
297
        }
298
        // writes `headers.ini` file
299
        $headers = $this->getBuilder()->getConfig()->get('server.headers');
300
        if (is_iterable($headers)) {
301
            $output->writeln('Writing headers file...');
302
            Util\File::getFS()->remove(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'));
303
            foreach ($headers as $entry) {
304
                Util\File::getFS()->appendToFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'), "[{$entry['path']}]\n");
305
                foreach ($entry['headers'] ?? [] as $header) {
306
                    Util\File::getFS()->appendToFile(Util::joinFile($this->getPath(), self::TMP_DIR, 'headers.ini'), "{$header['key']} = \"{$header['value']}\"\n");
307
                }
308
            }
309
        }
310
    }
311
312
    /**
313
     * Sets up the watcher.
314
     */
315
    private function setupWatcher(bool $noignorevcs = false): ResourceWatcher
316
    {
317
        $finder = new Finder();
318
        $finder->files()
319
            ->in($this->getPath())
320
            ->exclude((string) $this->getBuilder()->getConfig()->get('output.dir'));
321
        if (file_exists(Util::joinFile($this->getPath(), '.gitignore')) && $noignorevcs === false) {
322
            $finder->ignoreVCSIgnored(true);
323
        }
324
        return new ResourceWatcher(new ResourceCacheMemory(), $finder, new Crc32ContentHash());
325
    }
326
327
    /**
328
     * Prepares server's files.
329
     *
330
     * @throws RuntimeException
331
     */
332
    private function setUpServer(): void
333
    {
334
        try {
335
            // define root path
336
            $root = Util\Platform::isPhar() ? Util\Platform::getPharPath() . '/' : realpath(Util::joinFile(__DIR__, '/../../'));
337
            // copying router
338
            Util\File::getFS()->copy(
339
                $root . '/resources/server/router.php',
340
                Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php'),
341
                true
342
            );
343
            // copying livereload JS for watcher
344
            $livereloadJs = Util::joinFile($this->getPath(), self::TMP_DIR, 'livereload.js');
345
            if (is_file($livereloadJs)) {
346
                Util\File::getFS()->remove($livereloadJs);
347
            }
348
            if ($this->watcherEnabled) {
349
                Util\File::getFS()->copy(
350
                    $root . '/resources/server/livereload.js',
351
                    $livereloadJs,
352
                    true
353
                );
354
            }
355
        } catch (IOExceptionInterface $e) {
356
            throw new RuntimeException(\sprintf('An error occurred while copying server\'s files to "%s".', $e->getPath()));
357
        }
358
        if (!is_file(Util::joinFile($this->getPath(), self::TMP_DIR, 'router.php'))) {
359
            throw new RuntimeException(\sprintf('Router not found: "%s".', Util::joinFile(self::TMP_DIR, 'router.php')));
360
        }
361
    }
362
363
    /**
364
     * Removes temporary directory.
365
     *
366
     * @throws RuntimeException
367
     */
368
    public function tearDownServer(): void
369
    {
370
        $this->output->writeln('');
371
        $this->output->writeln('<info>Server stopped.</info>');
372
373
        try {
374
            Util\File::getFS()->remove(Util::joinFile($this->getPath(), self::TMP_DIR));
375
        } catch (IOExceptionInterface $e) {
376
            throw new RuntimeException($e->getMessage());
377
        }
378
    }
379
}
380