AbstractCommand   B
last analyzed

Complexity

Total Complexity 52

Size/Duplication

Total Lines 309
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 132
dl 0
loc 309
rs 7.44
c 0
b 0
f 0
wmc 52

12 Methods

Rating   Name   Duplication   Size   Complexity  
B run() 0 42 11
B initialize() 0 26 7
B getPath() 0 25 7
A getProcessedHelp() 0 13 2
A locateAdditionalConfigFiles() 0 13 3
A notification() 0 13 3
A locateConfigFile() 0 16 3
A getConfigFiles() 0 3 1
A getBuilder() 0 24 5
A validateUrl() 0 13 4
A binName() 0 3 1
A openEditor() 0 18 5

How to fix   Complexity   

Complex Class

Complex classes like AbstractCommand 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 AbstractCommand, 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\Builder;
17
use Cecil\Config;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Logger\ConsoleLogger;
20
use Cecil\Util;
21
use Joli\JoliNotif\DefaultNotifier;
22
use Joli\JoliNotif\Notification;
23
use Symfony\Component\Console\Command\Command;
24
use Symfony\Component\Console\Input\InputInterface;
25
use Symfony\Component\Console\Output\OutputInterface;
26
use Symfony\Component\Console\Style\SymfonyStyle;
27
use Symfony\Component\Filesystem\Path;
28
use Symfony\Component\Process\Process;
29
use Symfony\Component\Validator\Constraints\Url;
30
use Symfony\Component\Validator\Validation;
31
32
/**
33
 * Abstract command class.
34
 *
35
 * This class provides common functionality for all commands, such as configuration loading, path handling, and error management.
36
 */
37
class AbstractCommand extends Command
38
{
39
    public const CONFIG_FILE = ['cecil.yml', 'config.yml'];
40
    public const TMP_DIR = '.cecil';
41
    public const EXCLUDED_CMD = ['about', 'new:site', 'self-update'];
42
    public const SERVE_OUTPUT = '.cecil/preview';
43
44
    /** @var InputInterface */
45
    protected $input;
46
47
    /** @var OutputInterface */
48
    protected $output;
49
50
    /** @var SymfonyStyle */
51
    protected $io;
52
53
    /** @var string */
54
    protected $rootPath;
55
56
    /** @var null|string */
57
    private $path = null;
58
59
    /** @var array */
60
    private $configFiles = [];
61
62
    /** @var Config */
63
    private $config;
64
65
    /** @var Builder */
66
    private $builder;
67
68
    /**
69
     * {@inheritdoc}
70
     */
71
    protected function initialize(InputInterface $input, OutputInterface $output)
72
    {
73
        $this->input = $input;
74
        $this->output = $output;
75
        $this->io = new SymfonyStyle($input, $output);
76
        $this->rootPath = (Util\Platform::isPhar() ? Util\Platform::getPharPath() : realpath(Util::joinFile(__DIR__, '/../../'))) . '/';
77
78
        // prepare configuration files list
79
        if (!\in_array($this->getName(), self::EXCLUDED_CMD)) {
80
            // site config file
81
            $this->configFiles[$this->locateConfigFile($this->getPath())['name']] = $this->locateConfigFile($this->getPath())['path'];
82
            // additional config file(s) from --config=<file>
83
            if ($input->hasOption('config') && $input->getOption('config') !== null) {
84
                $this->configFiles += $this->locateAdditionalConfigFiles($this->getPath(), (string) $input->getOption('config'));
85
            }
86
            // checks file(s)
87
            $this->configFiles = array_unique($this->configFiles);
88
            foreach ($this->configFiles as $fileName => $filePath) {
89
                if ($filePath === false) {
90
                    unset($this->configFiles[$fileName]);
91
                    $this->io->warning(\sprintf('Could not find configuration file "%s".', $fileName));
92
                }
93
            }
94
        }
95
96
        parent::initialize($input, $output);
97
    }
98
99
    /**
100
     * {@inheritdoc}
101
     */
102
    public function run(InputInterface $input, OutputInterface $output): int
103
    {
104
        // disable debug mode if a verbosity level is specified
105
        if ($output->getVerbosity() != OutputInterface::VERBOSITY_NORMAL) {
106
            putenv('CECIL_DEBUG=false');
107
        }
108
        // force verbosity level to "debug" in debug mode
109
        if (getenv('CECIL_DEBUG') == 'true') {
110
            $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
111
        }
112
        if ($output->isDebug()) {
113
            // set env. variable in debug mode
114
            putenv('CECIL_DEBUG=true');
115
116
            return parent::run($input, $output);
117
        }
118
        // run with human error message
119
        try {
120
            return parent::run($input, $output);
121
        } catch (\Exception $e) {
122
            if ($this->io === null) {
123
                $this->io = new SymfonyStyle($input, $output);
124
            }
125
            $message = '';
126
            $i = 0;
127
            do {
128
                //if ($e instanceof \Twig\Error\RuntimeError) {
129
                //    continue;
130
                //}
131
132
                if ($i > 0) {
133
                    $message .= '└ ';
134
                }
135
                $message .= "{$e->getMessage()}\n";
136
                if ($e->getFile() && $e instanceof RuntimeException) {
137
                    $message .= \sprintf("→ %s%s\n", $e->getFile(), $e->getLine() ? ":{$e->getLine()}" : '');
138
                }
139
                $i++;
140
            } while ($e = $e->getPrevious());
141
            $this->io->error($message);
142
143
            exit(1);
144
        }
145
    }
146
147
    /**
148
     * Send desktop notification.
149
     */
150
    public function notification(string $body, ?string $url = null): void
151
    {
152
        $notifier = new DefaultNotifier();
153
        $notification = (new Notification())
154
            ->setTitle('Cecil')
155
            ->setIcon($this->rootPath . 'resources/icon.png')
156
            ->setBody($body)
157
        ;
158
        if ($url !== null) {
159
            $notification->addOption('url', $url);
160
        }
161
        if (false === $notifier->send($notification)) {
162
            $this->output->writeln('<comment>Desktop notification could not be sent</comment>');
163
        }
164
    }
165
166
    /**
167
     * Returns the working path.
168
     */
169
    protected function getPath(bool $exist = true): ?string
170
    {
171
        if ($this->path === null) {
172
            try {
173
                // get working directory by default
174
                if (false === $this->path = getcwd()) {
175
                    throw new \Exception('Unable to get current working directory.');
176
                }
177
                // ... or path
178
                if ($this->input->getArgument('path') !== null) {
179
                    $this->path = Path::canonicalize($this->input->getArgument('path'));
180
                }
181
                // try to get canonicalized absolute path
182
                if ($exist) {
183
                    if (realpath($this->path) === false) {
184
                        throw new \Exception(\sprintf('The given path "%s" is not valid.', $this->path));
185
                    }
186
                    $this->path = realpath($this->path);
187
                }
188
            } catch (\Exception $e) {
189
                throw new \Exception($e->getMessage());
190
            }
191
        }
192
193
        return $this->path;
194
    }
195
196
    /**
197
     * Returns config file(s) path.
198
     */
199
    protected function getConfigFiles(): array
200
    {
201
        return $this->configFiles ?? [];
202
    }
203
204
    /**
205
     * Creates or returns a Builder instance.
206
     *
207
     * @throws RuntimeException
208
     */
209
    protected function getBuilder(array $config = []): Builder
210
    {
211
        try {
212
            // loads configuration files if not already done
213
            if ($this->config === null) {
214
                $this->config = new Config();
215
                // loads and merges configuration files
216
                foreach ($this->getConfigFiles() as $filePath) {
217
                    $this->config->import($this->config::loadFile($filePath), Config::IMPORT_MERGE);
218
                }
219
                // merges configuration from $config parameter
220
                $this->config->import($config, Config::IMPORT_MERGE);
221
            }
222
            // creates builder instance if not already done
223
            if ($this->builder === null) {
224
                $this->builder = (new Builder($this->config, new ConsoleLogger($this->output)))
225
                    ->setSourceDir($this->getPath())
226
                    ->setDestinationDir($this->getPath());
227
            }
228
        } catch (\Exception $e) {
229
            throw new RuntimeException($e->getMessage());
230
        }
231
232
        return $this->builder;
233
    }
234
235
    /**
236
     * Locates the configuration in the given path, as an array of the file name and path, if file exists, otherwise default name and false.
237
     */
238
    protected function locateConfigFile(string $path): array
239
    {
240
        $config = [
241
            'name' => self::CONFIG_FILE[0],
242
            'path' => false,
243
        ];
244
        foreach (self::CONFIG_FILE as $configFileName) {
245
            if (($configFilePath = realpath(Util::joinFile($path, $configFileName))) !== false) {
246
                $config = [
247
                    'name' => $configFileName,
248
                    'path' => $configFilePath,
249
                ];
250
            }
251
        }
252
253
        return $config;
254
    }
255
256
    /**
257
     * Locates additional configuration file(s) from the given list of files, relative to the given path or absolute.
258
     */
259
    protected function locateAdditionalConfigFiles(string $path, string $configFilesList): array
260
    {
261
        $config = [];
262
        foreach (explode(',', $configFilesList) as $filename) {
263
            // absolute path
264
            $config[$filename] = realpath($filename);
265
            // relative path
266
            if (!Util\File::getFS()->isAbsolutePath($filename)) {
267
                $config[$filename] = realpath(Util::joinFile($path, $filename));
268
            }
269
        }
270
271
        return $config;
272
    }
273
274
    /**
275
     * Opens path with editor.
276
     *
277
     * @throws RuntimeException
278
     */
279
    protected function openEditor(string $path, string $editor): void
280
    {
281
        $command = \sprintf('%s "%s"', $editor, $path);
282
        switch (Util\Platform::getOS()) {
283
            case Util\Platform::OS_WIN:
284
                $command = \sprintf('start /B "" %s "%s"', $editor, $path);
285
                break;
286
            case Util\Platform::OS_OSX:
287
                // Typora on macOS
288
                if ($editor == 'typora') {
289
                    $command = \sprintf('open -a typora "%s"', $path);
290
                }
291
                break;
292
        }
293
        $process = Process::fromShellCommandline($command);
294
        $process->run();
295
        if (!$process->isSuccessful()) {
296
            throw new RuntimeException(\sprintf('Unable to use "%s" editor.', $editor));
297
        }
298
    }
299
300
    /**
301
     * Validate URL.
302
     *
303
     * @throws RuntimeException
304
     */
305
    public static function validateUrl(string $url): string
306
    {
307
        if ($url == '/') { // tolerate root URL
308
            return $url;
309
        }
310
        $validator = Validation::createValidator();
311
        $violations = $validator->validate($url, new Url());
312
        if (\count($violations) > 0) {
313
            foreach ($violations as $violation) {
314
                throw new RuntimeException($violation->getMessage());
315
            }
316
        }
317
        return rtrim($url, '/') . '/';
318
    }
319
320
    /**
321
     * Returns the "binary name" in the console context.
322
     */
323
    protected function binName(): string
324
    {
325
        return basename($_SERVER['argv'][0]);
326
    }
327
328
    /**
329
     * Override default help message.
330
     *
331
     * @return string
332
     */
333
    public function getProcessedHelp(): string
334
    {
335
        $name = $this->getName();
336
        $placeholders = [
337
            '%command.name%',
338
            '%command.full_name%',
339
        ];
340
        $replacements = [
341
            $name,
342
            $this->binName() . ' ' . $name,
343
        ];
344
345
        return str_replace($placeholders, $replacements, $this->getHelp() ?: $this->getDescription());
346
    }
347
}
348