AbstractCommand   B
last analyzed

Complexity

Total Complexity 51

Size/Duplication

Total Lines 306
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 130
dl 0
loc 306
rs 7.92
c 0
b 0
f 0
wmc 51

12 Methods

Rating   Name   Duplication   Size   Complexity  
B getPath() 0 25 7
B run() 0 42 11
B initialize() 0 26 7
A getProcessedHelp() 0 13 2
A locateAdditionalConfigFiles() 0 13 3
A notification() 0 10 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 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): 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 (false === $notifier->send($notification)) {
159
            $this->output->writeln('<comment>Desktop notification could not be sent</comment>');
160
        }
161
    }
162
163
    /**
164
     * Returns the working path.
165
     */
166
    protected function getPath(bool $exist = true): ?string
167
    {
168
        if ($this->path === null) {
169
            try {
170
                // get working directory by default
171
                if (false === $this->path = getcwd()) {
172
                    throw new \Exception('Unable to get current working directory.');
173
                }
174
                // ... or path
175
                if ($this->input->getArgument('path') !== null) {
176
                    $this->path = Path::canonicalize($this->input->getArgument('path'));
177
                }
178
                // try to get canonicalized absolute path
179
                if ($exist) {
180
                    if (realpath($this->path) === false) {
181
                        throw new \Exception(\sprintf('The given path "%s" is not valid.', $this->path));
182
                    }
183
                    $this->path = realpath($this->path);
184
                }
185
            } catch (\Exception $e) {
186
                throw new \Exception($e->getMessage());
187
            }
188
        }
189
190
        return $this->path;
191
    }
192
193
    /**
194
     * Returns config file(s) path.
195
     */
196
    protected function getConfigFiles(): array
197
    {
198
        return $this->configFiles ?? [];
199
    }
200
201
    /**
202
     * Creates or returns a Builder instance.
203
     *
204
     * @throws RuntimeException
205
     */
206
    protected function getBuilder(array $config = []): Builder
207
    {
208
        try {
209
            // loads configuration files if not already done
210
            if ($this->config === null) {
211
                $this->config = new Config();
212
                // loads and merges configuration files
213
                foreach ($this->getConfigFiles() as $filePath) {
214
                    $this->config->import($this->config::loadFile($filePath), Config::IMPORT_MERGE);
215
                }
216
                // merges configuration from $config parameter
217
                $this->config->import($config, Config::IMPORT_MERGE);
218
            }
219
            // creates builder instance if not already done
220
            if ($this->builder === null) {
221
                $this->builder = (new Builder($this->config, new ConsoleLogger($this->output)))
222
                    ->setSourceDir($this->getPath())
223
                    ->setDestinationDir($this->getPath());
224
            }
225
        } catch (\Exception $e) {
226
            throw new RuntimeException($e->getMessage());
227
        }
228
229
        return $this->builder;
230
    }
231
232
    /**
233
     * Locates the configuration in the given path, as an array of the file name and path, if file exists, otherwise default name and false.
234
     */
235
    protected function locateConfigFile(string $path): array
236
    {
237
        $config = [
238
            'name' => self::CONFIG_FILE[0],
239
            'path' => false,
240
        ];
241
        foreach (self::CONFIG_FILE as $configFileName) {
242
            if (($configFilePath = realpath(Util::joinFile($path, $configFileName))) !== false) {
243
                $config = [
244
                    'name' => $configFileName,
245
                    'path' => $configFilePath,
246
                ];
247
            }
248
        }
249
250
        return $config;
251
    }
252
253
    /**
254
     * Locates additional configuration file(s) from the given list of files, relative to the given path or absolute.
255
     */
256
    protected function locateAdditionalConfigFiles(string $path, string $configFilesList): array
257
    {
258
        $config = [];
259
        foreach (explode(',', $configFilesList) as $filename) {
260
            // absolute path
261
            $config[$filename] = realpath($filename);
262
            // relative path
263
            if (!Util\File::getFS()->isAbsolutePath($filename)) {
264
                $config[$filename] = realpath(Util::joinFile($path, $filename));
265
            }
266
        }
267
268
        return $config;
269
    }
270
271
    /**
272
     * Opens path with editor.
273
     *
274
     * @throws RuntimeException
275
     */
276
    protected function openEditor(string $path, string $editor): void
277
    {
278
        $command = \sprintf('%s "%s"', $editor, $path);
279
        switch (Util\Platform::getOS()) {
280
            case Util\Platform::OS_WIN:
281
                $command = \sprintf('start /B "" %s "%s"', $editor, $path);
282
                break;
283
            case Util\Platform::OS_OSX:
284
                // Typora on macOS
285
                if ($editor == 'typora') {
286
                    $command = \sprintf('open -a typora "%s"', $path);
287
                }
288
                break;
289
        }
290
        $process = Process::fromShellCommandline($command);
291
        $process->run();
292
        if (!$process->isSuccessful()) {
293
            throw new RuntimeException(\sprintf('Unable to use "%s" editor.', $editor));
294
        }
295
    }
296
297
    /**
298
     * Validate URL.
299
     *
300
     * @throws RuntimeException
301
     */
302
    public static function validateUrl(string $url): string
303
    {
304
        if ($url == '/') { // tolerate root URL
305
            return $url;
306
        }
307
        $validator = Validation::createValidator();
308
        $violations = $validator->validate($url, new Url());
309
        if (\count($violations) > 0) {
310
            foreach ($violations as $violation) {
311
                throw new RuntimeException($violation->getMessage());
312
            }
313
        }
314
        return rtrim($url, '/') . '/';
315
    }
316
317
    /**
318
     * Returns the "binary name" in the console context.
319
     */
320
    protected function binName(): string
321
    {
322
        return basename($_SERVER['argv'][0]);
323
    }
324
325
    /**
326
     * Override default help message.
327
     *
328
     * @return string
329
     */
330
    public function getProcessedHelp(): string
331
    {
332
        $name = $this->getName();
333
        $placeholders = [
334
            '%command.name%',
335
            '%command.full_name%',
336
        ];
337
        $replacements = [
338
            $name,
339
            $this->binName() . ' ' . $name,
340
        ];
341
342
        return str_replace($placeholders, $replacements, $this->getHelp() ?: $this->getDescription());
343
    }
344
}
345