Passed
Push — php-8.2 ( bc0725...330406 )
by Arnaud
03:10
created

AbstractCommand   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 295
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 126
dl 0
loc 295
rs 8.5599
c 0
b 0
f 0
wmc 48

11 Methods

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