AbstractCommand   C
last analyzed

Complexity

Total Complexity 55

Size/Duplication

Total Lines 328
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 55
eloc 139
c 0
b 0
f 0
dl 0
loc 328
rs 6

13 Methods

Rating   Name   Duplication   Size   Complexity  
B run() 0 42 11
B getPath() 0 25 7
B initialize() 0 26 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 validateUrl() 0 13 4
B getBuilder() 0 32 7
A binName() 0 3 1
A openEditor() 0 18 5
A setContainer() 0 3 1

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 Symfony\Component\DependencyInjection\ContainerInterface;
22
use Joli\JoliNotif\DefaultNotifier;
23
use Joli\JoliNotif\Notification;
24
use Symfony\Component\Console\Command\Command;
25
use Symfony\Component\Console\Input\InputInterface;
26
use Symfony\Component\Console\Output\OutputInterface;
27
use Symfony\Component\Console\Style\SymfonyStyle;
28
use Symfony\Component\Filesystem\Path;
29
use Symfony\Component\Process\Process;
30
use Symfony\Component\Validator\Constraints\Url;
31
use Symfony\Component\Validator\Validation;
32
33
/**
34
 * Abstract command class.
35
 *
36
 * This class provides common functionality for all commands, such as configuration loading, path handling, and error management.
37
 */
38
class AbstractCommand extends Command
39
{
40
    public const CONFIG_FILE = ['cecil.yml', 'config.yml'];
41
    public const TMP_DIR = '.cecil';
42
    public const EXCLUDED_CMD = ['about', 'new:site', 'self-update'];
43
    public const SERVE_OUTPUT = '.cecil/preview';
44
45
    /** @var InputInterface */
46
    protected $input;
47
48
    /** @var OutputInterface */
49
    protected $output;
50
51
    /** @var SymfonyStyle */
52
    protected $io;
53
54
    /** @var string */
55
    protected $rootPath;
56
57
    /** @var null|string */
58
    private $path = null;
59
60
    /** @var array */
61
    private $configFiles = [];
62
63
    /** @var Config */
64
    private $config;
65
66
    /** @var Builder */
67
    private $builder;
68
69
    /** @var ContainerInterface|null */
70
    private $container;
71
72
    /**
73
     * {@inheritdoc}
74
     */
75
    protected function initialize(InputInterface $input, OutputInterface $output)
76
    {
77
        $this->input = $input;
78
        $this->output = $output;
79
        $this->io = new SymfonyStyle($input, $output);
80
        $this->rootPath = (Util\Platform::isPhar() ? Util\Platform::getPharPath() : realpath(Util::joinFile(__DIR__, '/../../'))) . '/';
81
82
        // prepare configuration files list
83
        if (!\in_array($this->getName(), self::EXCLUDED_CMD)) {
84
            // site config file
85
            $this->configFiles[$this->locateConfigFile($this->getPath())['name']] = $this->locateConfigFile($this->getPath())['path'];
86
            // additional config file(s) from --config=<file>
87
            if ($input->hasOption('config') && $input->getOption('config') !== null) {
88
                $this->configFiles += $this->locateAdditionalConfigFiles($this->getPath(), (string) $input->getOption('config'));
89
            }
90
            // checks file(s)
91
            $this->configFiles = array_unique($this->configFiles);
92
            foreach ($this->configFiles as $fileName => $filePath) {
93
                if ($filePath === false) {
94
                    unset($this->configFiles[$fileName]);
95
                    $this->io->warning(\sprintf('Could not find configuration file "%s".', $fileName));
96
                }
97
            }
98
        }
99
100
        parent::initialize($input, $output);
101
    }
102
103
    /**
104
     * {@inheritdoc}
105
     */
106
    public function run(InputInterface $input, OutputInterface $output): int
107
    {
108
        // disable debug mode if a verbosity level is specified
109
        if ($output->getVerbosity() != OutputInterface::VERBOSITY_NORMAL) {
110
            putenv('CECIL_DEBUG=false');
111
        }
112
        // force verbosity level to "debug" in debug mode
113
        if (getenv('CECIL_DEBUG') == 'true') {
114
            $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
115
        }
116
        if ($output->isDebug()) {
117
            // set env. variable in debug mode
118
            putenv('CECIL_DEBUG=true');
119
120
            return parent::run($input, $output);
121
        }
122
        // run with human error message
123
        try {
124
            return parent::run($input, $output);
125
        } catch (\Exception $e) {
126
            if ($this->io === null) {
127
                $this->io = new SymfonyStyle($input, $output);
128
            }
129
            $message = '';
130
            $i = 0;
131
            do {
132
                //if ($e instanceof \Twig\Error\RuntimeError) {
133
                //    continue;
134
                //}
135
136
                if ($i > 0) {
137
                    $message .= '└ ';
138
                }
139
                $message .= "{$e->getMessage()}\n";
140
                if ($e->getFile() && $e instanceof RuntimeException) {
141
                    $message .= \sprintf("→ %s%s\n", $e->getFile(), $e->getLine() ? ":{$e->getLine()}" : '');
142
                }
143
                $i++;
144
            } while ($e = $e->getPrevious());
145
            $this->io->error($message);
146
147
            exit(1);
148
        }
149
    }
150
151
    /**
152
     * Send desktop notification.
153
     */
154
    public function notification(string $body, ?string $url = null): void
155
    {
156
        $notifier = new DefaultNotifier();
157
        $notification = (new Notification())
158
            ->setTitle('Cecil')
159
            ->setIcon($this->rootPath . 'resources/icon.png')
160
            ->setBody($body)
161
        ;
162
        if ($url !== null) {
163
            $notification->addOption('url', $url);
164
        }
165
        if (false === $notifier->send($notification)) {
166
            $this->output->writeln('<comment>Desktop notification could not be sent</comment>');
167
        }
168
    }
169
170
    /**
171
     * Returns the working path.
172
     */
173
    protected function getPath(bool $exist = true): ?string
174
    {
175
        if ($this->path === null) {
176
            try {
177
                // get working directory by default
178
                if (false === $this->path = getcwd()) {
179
                    throw new \Exception('Unable to get current working directory.');
180
                }
181
                // ... or path
182
                if ($this->input->getArgument('path') !== null) {
183
                    $this->path = Path::canonicalize($this->input->getArgument('path'));
184
                }
185
                // try to get canonicalized absolute path
186
                if ($exist) {
187
                    if (realpath($this->path) === false) {
188
                        throw new \Exception(\sprintf('The given path "%s" is not valid.', $this->path));
189
                    }
190
                    $this->path = realpath($this->path);
191
                }
192
            } catch (\Exception $e) {
193
                throw new \Exception($e->getMessage());
194
            }
195
        }
196
197
        return $this->path;
198
    }
199
200
    /**
201
     * Returns config file(s) path.
202
     */
203
    protected function getConfigFiles(): array
204
    {
205
        return $this->configFiles ?? [];
206
    }
207
208
    /**
209
     * Sets the DI container.
210
     */
211
    public function setContainer(?ContainerInterface $container): void
212
    {
213
        $this->container = $container;
214
    }
215
216
    /**
217
     * Creates or returns a Builder instance.
218
     *
219
     * @throws RuntimeException
220
     */
221
    protected function getBuilder(array $config = []): Builder
222
    {
223
        try {
224
            // loads configuration files if not already done
225
            if ($this->config === null) {
226
                $this->config = new Config();
227
                // loads and merges configuration files
228
                foreach ($this->getConfigFiles() as $filePath) {
229
                    $this->config->import($this->config::loadFile($filePath), Config::IMPORT_MERGE);
230
                }
231
                // merges configuration from $config parameter
232
                $this->config->import($config, Config::IMPORT_MERGE);
233
            }
234
            // creates builder instance if not already done
235
            if ($this->builder === null) {
236
                // Use container if available
237
                if ($this->container !== null && $this->container->has('Cecil\\Builder')) {
238
                    $this->builder = $this->container->get('Cecil\\Builder');
239
                } else {
240
                    // Direct instantiation with fallback container
241
                    $fallbackContainer = $this->container ?? new \Symfony\Component\DependencyInjection\ContainerBuilder();
242
                    $this->builder = new Builder($this->config, new ConsoleLogger($this->output), $fallbackContainer);
243
                }
244
                $this->builder
245
                    ->setSourceDir($this->getPath())
0 ignored issues
show
Bug introduced by
The method setSourceDir() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

245
                    ->/** @scrutinizer ignore-call */ 
246
                      setSourceDir($this->getPath())

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
246
                    ->setDestinationDir($this->getPath());
247
            }
248
        } catch (\Exception $e) {
249
            throw new RuntimeException($e->getMessage());
250
        }
251
252
        return $this->builder;
253
    }
254
255
    /**
256
     * Locates the configuration in the given path, as an array of the file name and path, if file exists, otherwise default name and false.
257
     */
258
    protected function locateConfigFile(string $path): array
259
    {
260
        $config = [
261
            'name' => self::CONFIG_FILE[0],
262
            'path' => false,
263
        ];
264
        foreach (self::CONFIG_FILE as $configFileName) {
265
            if (($configFilePath = realpath(Util::joinFile($path, $configFileName))) !== false) {
266
                $config = [
267
                    'name' => $configFileName,
268
                    'path' => $configFilePath,
269
                ];
270
            }
271
        }
272
273
        return $config;
274
    }
275
276
    /**
277
     * Locates additional configuration file(s) from the given list of files, relative to the given path or absolute.
278
     */
279
    protected function locateAdditionalConfigFiles(string $path, string $configFilesList): array
280
    {
281
        $config = [];
282
        foreach (explode(',', $configFilesList) as $filename) {
283
            // absolute path
284
            $config[$filename] = realpath($filename);
285
            // relative path
286
            if (!Util\File::getFS()->isAbsolutePath($filename)) {
287
                $config[$filename] = realpath(Util::joinFile($path, $filename));
288
            }
289
        }
290
291
        return $config;
292
    }
293
294
    /**
295
     * Opens path with editor.
296
     *
297
     * @throws RuntimeException
298
     */
299
    protected function openEditor(string $path, string $editor): void
300
    {
301
        $command = \sprintf('%s "%s"', $editor, $path);
302
        switch (Util\Platform::getOS()) {
303
            case Util\Platform::OS_WIN:
304
                $command = \sprintf('start /B "" %s "%s"', $editor, $path);
305
                break;
306
            case Util\Platform::OS_OSX:
307
                // Typora on macOS
308
                if ($editor == 'typora') {
309
                    $command = \sprintf('open -a typora "%s"', $path);
310
                }
311
                break;
312
        }
313
        $process = Process::fromShellCommandline($command);
314
        $process->run();
315
        if (!$process->isSuccessful()) {
316
            throw new RuntimeException(\sprintf('Unable to use "%s" editor.', $editor));
317
        }
318
    }
319
320
    /**
321
     * Validate URL.
322
     *
323
     * @throws RuntimeException
324
     */
325
    public static function validateUrl(string $url): string
326
    {
327
        if ($url == '/') { // tolerate root URL
328
            return $url;
329
        }
330
        $validator = Validation::createValidator();
331
        $violations = $validator->validate($url, new Url());
332
        if (\count($violations) > 0) {
333
            foreach ($violations as $violation) {
334
                throw new RuntimeException($violation->getMessage());
335
            }
336
        }
337
        return rtrim($url, '/') . '/';
338
    }
339
340
    /**
341
     * Returns the "binary name" in the console context.
342
     */
343
    protected function binName(): string
344
    {
345
        return basename($_SERVER['argv'][0]);
346
    }
347
348
    /**
349
     * Override default help message.
350
     *
351
     * @return string
352
     */
353
    public function getProcessedHelp(): string
354
    {
355
        $name = $this->getName();
356
        $placeholders = [
357
            '%command.name%',
358
            '%command.full_name%',
359
        ];
360
        $replacements = [
361
            $name,
362
            $this->binName() . ' ' . $name,
363
        ];
364
365
        return str_replace($placeholders, $replacements, $this->getHelp() ?: $this->getDescription());
366
    }
367
}
368