AbstractCommand::getConfigFiles()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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