AbstractCommand   B
last analyzed

Complexity

Total Complexity 48

Size/Duplication

Total Lines 286
Duplicated Lines 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
eloc 121
c 4
b 1
f 0
dl 0
loc 286
rs 8.5599
wmc 48

11 Methods

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