AbstractCommand   B
last analyzed

Complexity

Total Complexity 51

Size/Duplication

Total Lines 310
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 131
dl 0
loc 310
rs 7.92
c 0
b 0
f 0
wmc 51

12 Methods

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