Passed
Push — master ( 999d01...efdff3 )
by Arnaud
05:43
created

Config   F

Complexity

Total Complexity 96

Size/Duplication

Total Lines 618
Duplicated Lines 0 %

Test Coverage

Coverage 75%

Importance

Changes 4
Bugs 1 Features 0
Metric Value
wmc 96
eloc 174
c 4
b 1
f 0
dl 0
loc 618
ccs 153
cts 204
cp 0.75
rs 2

41 Methods

Rating   Name   Duplication   Size   Complexity  
A getTheme() 0 11 3
A getThemeDirPath() 0 3 1
A hasTheme() 0 13 5
A isCacheDirIsAbsolute() 0 8 2
A getAssetsImagesWidths() 0 3 1
A getLanguageDefault() 0 14 4
A getAssetsImagesSizes() 0 3 1
A export() 0 3 1
A import() 0 5 1
A has() 0 19 6
A getThemesPath() 0 3 1
A getStaticPath() 0 3 1
A getLayoutSection() 0 7 2
A getLayoutsInternalPath() 0 3 1
A getDataPath() 0 3 1
A getLayoutsPath() 0 3 1
A getOutputPath() 0 3 1
A getTranslationsPath() 0 3 1
A getTranslationsInternalPath() 0 3 1
A getPagesPath() 0 3 1
A setDestinationDir() 0 8 2
A loadFile() 0 15 5
A getDestinationDir() 0 7 2
B get() 0 18 7
A setSourceDir() 0 8 2
A __construct() 0 11 2
A getSourceDir() 0 7 2
B isEnabled() 0 16 7
A getLanguageIndex() 0 8 2
A getCachePath() 0 13 3
A getCacheAssetsPath() 0 3 1
A getOutputFormatProperty() 0 8 2
A castSetValue() 0 9 2
A getAssetsRemotePath() 0 3 1
A getLanguageProperty() 0 9 2
A getCacheTranslationsPath() 0 3 1
A getLanguages() 0 14 4
A getCacheTemplatesPath() 0 3 1
A getAssetsPath() 0 3 1
B validate() 0 33 8
A setFromEnv() 0 5 3

How to fix   Complexity   

Complex Class

Complex classes like Config 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 Config, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil;
15
16
use Cecil\Exception\ConfigException;
17
use Dflydev\DotAccessData\Data;
18
use Symfony\Component\Yaml\Exception\ParseException;
19
use Symfony\Component\Yaml\Yaml;
20
21
/**
22
 * Class Config.
23
 */
24
class Config
25
{
26
    /** Configuration is a Data object. */
27
    protected Data $data;
28
29
    /** Default configuration is a Data object. */
30
    protected Data $default;
31
32
    /** Source directory. */
33
    protected ?string $sourceDir = null;
34
35
    /** Destination directory. */
36
    protected ?string $destinationDir = null;
37
38
    /** Languages list as array. */
39
    protected ?array $languages = null;
40
41
    public const PRESERVE = 0;
42
    public const REPLACE = 1;
43
    public const MERGE = 2;
44
    public const LANG_CODE_PATTERN = '([a-z]{2}(-[A-Z]{2})?)'; // "fr" or "fr-FR"
45
    public const LANG_LOCALE_PATTERN = '[a-z]{2}(_[A-Z]{2})?(_[A-Z]{2})?'; // "fr" or "fr_FR" or "no_NO_NY"
46
47
    /**
48
     * Build the Config object with the default config + the optional given array.
49
     */
50 1
    public function __construct(?array $config = null)
51
    {
52
        // default configuration
53 1
        $defaultConfigFile = Util\File::getRealPath('../config/default.php');
54 1
        $this->default = new Data(include $defaultConfigFile);
55
        // base configuration
56 1
        $baseConfigFile = Util\File::getRealPath('../config/base.php');
57 1
        $this->data = new Data(include $baseConfigFile);
58
        // import config array if provided
59 1
        if ($config !== null) {
60 1
            $this->import($config);
61
        }
62
    }
63
64
    /**
65
     * Imports (and validate) configuration.
66
     * The mode can be: Config::PRESERVE, Config::REPLACE or Config::MERGE.
67
     */
68 1
    public function import(array $config, $mode = self::MERGE): void
69
    {
70 1
        $this->data->import($config, $mode);
71 1
        $this->setFromEnv(); // override configuration with environment variables
72 1
        $this->validate();   // validate configuration
73
    }
74
75
    /**
76
     * Get configuration as an array.
77
     */
78
    public function export(): array
79
    {
80
        return $this->data->export();
81
    }
82
83
    /**
84
     * Loads and parse a YAML file.
85
     */
86 1
    public static function loadFile(string $file, bool $ignore = false): array
87
    {
88 1
        if (!Util\File::getFS()->exists($file)) {
89 1
            if ($ignore) {
90 1
                return [];
91
            }
92
            throw new ConfigException(\sprintf('File "%s" does not exist.', $file));
93
        }
94 1
        if (false === $fileContent = Util\File::fileGetContents($file)) {
95
            throw new ConfigException(\sprintf('Can\'t read file "%s".', $file));
96
        }
97
        try {
98 1
            return Yaml::parse($fileContent, Yaml::PARSE_DATETIME) ?? [];
99
        } catch (ParseException $e) {
100
            throw new ConfigException(\sprintf('"%s" parsing error: %s', $file, $e->getMessage()));
101
        }
102
    }
103
104
    /**
105
     * Is configuration's key exists?
106
     *
107
     * @param string $key      Configuration key
108
     * @param string $language Language code (optional)
109
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
110
     */
111 1
    public function has(string $key, ?string $language = null, bool $fallback = true): bool
112
    {
113 1
        $default = $this->default->has($key);
114
115 1
        if ($language !== null) {
116
            $langIndex = $this->getLanguageIndex($language);
117
            $keyLang = "languages.$langIndex.config.$key";
118
            if ($this->data->has($keyLang)) {
119
                return true;
120
            }
121
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
122
                return $default;
123
            }
124
        }
125 1
        if ($this->data->has($key)) {
126 1
            return true;
127
        }
128
129 1
        return $default;
130
    }
131
132
    /**
133
     * Get the value of a configuration's key.
134
     *
135
     * @param string $key      Configuration key
136
     * @param string $language Language code (optional)
137
     * @param bool   $fallback Set to false to not return the value in the default language as fallback
138
     *
139
     * @return mixed|null
140
     */
141 1
    public function get(string $key, ?string $language = null, bool $fallback = true)
142
    {
143 1
        $default = $this->default->has($key) ? $this->default->get($key) : null;
144 1
        if ($language !== null) {
145 1
            $langIndex = $this->getLanguageIndex($language);
146 1
            $keyLang = "languages.$langIndex.config.$key";
147 1
            if ($this->data->has($keyLang)) {
148 1
                return $this->data->get($keyLang);
149
            }
150 1
            if ($language !== $this->getLanguageDefault() && $fallback === false) {
151 1
                return $default;
152
            }
153
        }
154 1
        if ($this->data->has($key)) {
155 1
            return $this->data->get($key);
156
        }
157
158 1
        return $default;
159
    }
160
161
    /**
162
     * Is an option is enabled?
163
     * Checks if the key is set to `false` or if subkey `enabled` is set to `false`.
164
     */
165 1
    public function isEnabled(string $key, ?string $language = null, bool $fallback = true): bool
166
    {
167 1
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) === true) {
168 1
            return true;
169
        }
170 1
        if ($this->has("$key.enabled", $language, $fallback)) {
171 1
            if ($this->get("$key.enabled", $language, $fallback) === true) {
172 1
                return true;
173
            }
174
            return false;
175
        }
176 1
        if ($this->has($key, $language, $fallback) && $this->get($key, $language, $fallback) !== false) {
177 1
            return true;
178
        }
179
180 1
        return false;
181
    }
182
183
    /**
184
     * Set the source directory.
185
     *
186
     * @throws \InvalidArgumentException
187
     */
188 1
    public function setSourceDir(string $sourceDir): self
189
    {
190 1
        if (!is_dir($sourceDir)) {
191
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid source.', $sourceDir));
192
        }
193 1
        $this->sourceDir = $sourceDir;
194
195 1
        return $this;
196
    }
197
198
    /**
199
     * Get the source directory.
200
     */
201 1
    public function getSourceDir(): string
202
    {
203 1
        if ($this->sourceDir === null) {
204 1
            return getcwd();
205
        }
206
207 1
        return $this->sourceDir;
208
    }
209
210
    /**
211
     * Set the destination directory.
212
     *
213
     * @throws \InvalidArgumentException
214
     */
215 1
    public function setDestinationDir(string $destinationDir): self
216
    {
217 1
        if (!is_dir($destinationDir)) {
218
            throw new \InvalidArgumentException(\sprintf('The directory "%s" is not a valid destination.', $destinationDir));
219
        }
220 1
        $this->destinationDir = $destinationDir;
221
222 1
        return $this;
223
    }
224
225
    /**
226
     * Get the destination directory.
227
     */
228 1
    public function getDestinationDir(): string
229
    {
230 1
        if ($this->destinationDir === null) {
231
            return $this->getSourceDir();
232
        }
233
234 1
        return $this->destinationDir;
235
    }
236
237
    /*
238
     * Path helpers.
239
     */
240
241
    /**
242
     * Returns the path of the pages directory.
243
     */
244 1
    public function getPagesPath(): string
245
    {
246 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('pages.dir'));
247
    }
248
249
    /**
250
     * Returns the path of the output directory.
251
     */
252 1
    public function getOutputPath(): string
253
    {
254 1
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('output.dir'));
255
    }
256
257
    /**
258
     * Returns the path of the data directory.
259
     */
260 1
    public function getDataPath(): string
261
    {
262 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('data.dir'));
263
    }
264
265
    /**
266
     * Returns the path of templates directory.
267
     */
268 1
    public function getLayoutsPath(): string
269
    {
270 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.dir'));
271
    }
272
273
    /**
274
     * Returns the path of internal templates directory.
275
     */
276 1
    public function getLayoutsInternalPath(): string
277
    {
278 1
        return __DIR__ . '/../resources/layouts';
279
    }
280
281
    /**
282
     * Returns the layout for a section.
283
     */
284 1
    public function getLayoutSection(?string $section): ?string
285
    {
286 1
        if ($layout = $this->get('layouts.sections')[$section] ?? null) {
287
            return $layout;
288
        }
289
290 1
        return $section;
291
    }
292
293
    /**
294
     * Returns the path of translations directory.
295
     */
296 1
    public function getTranslationsPath(): string
297
    {
298 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('layouts.translations.dir'));
299
    }
300
301
    /**
302
     * Returns the path of internal translations directory.
303
     */
304 1
    public function getTranslationsInternalPath(): string
305
    {
306 1
        return Util\File::getRealPath('../resources/translations');
307
    }
308
309
    /**
310
     * Returns the path of themes directory.
311
     */
312 1
    public function getThemesPath(): string
313
    {
314 1
        return Util::joinFile($this->getSourceDir(), 'themes');
315
    }
316
317
    /**
318
     * Returns the path of static files directory.
319
     */
320 1
    public function getStaticPath(): string
321
    {
322 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('static.dir'));
323
    }
324
325
    /**
326
     * Returns the path of assets files directory.
327
     */
328 1
    public function getAssetsPath(): string
329
    {
330 1
        return Util::joinFile($this->getSourceDir(), (string) $this->get('assets.dir'));
331
    }
332
333
    /**
334
     * Returns the path of remote assets files directory (in cache).
335
     *
336
     * @return string
337
     */
338
    public function getAssetsRemotePath(): string
339
    {
340
        return Util::joinFile($this->getCacheAssetsPath(), 'remote');
341
    }
342
343
    /**
344
     * Returns cache path.
345
     *
346
     * @throws ConfigException
347
     */
348 1
    public function getCachePath(): string
349
    {
350 1
        if (empty((string) $this->get('cache.dir'))) {
351
            throw new ConfigException(\sprintf('The cache directory (`%s`) is not defined.', 'cache.dir'));
352
        }
353 1
        if ($this->isCacheDirIsAbsolute()) {
354
            $cacheDir = Util::joinFile((string) $this->get('cache.dir'), 'cecil');
355
            Util\File::getFS()->mkdir($cacheDir);
356
357
            return $cacheDir;
358
        }
359
360 1
        return Util::joinFile($this->getDestinationDir(), (string) $this->get('cache.dir'));
361
    }
362
363
    /**
364
     * Returns cache path of templates.
365
     */
366 1
    public function getCacheTemplatesPath(): string
367
    {
368 1
        return Util::joinFile($this->getCachePath(), 'templates');
369
    }
370
371
    /**
372
     * Returns cache path of translations.
373
     */
374 1
    public function getCacheTranslationsPath(): string
375
    {
376 1
        return Util::joinFile($this->getCachePath(), 'translations');
377
    }
378
379
    /**
380
     * Returns cache path of assets.
381
     */
382
    public function getCacheAssetsPath(): string
383
    {
384
        return Util::joinFile($this->getCachePath(), 'assets');
385
    }
386
387
    /*
388
     * Output helpers.
389
     */
390
391
    /**
392
     * Returns the property value of an output format.
393
     *
394
     * @throws ConfigException
395
     */
396 1
    public function getOutputFormatProperty(string $name, string $property): string|array|null
397
    {
398 1
        $properties = array_column((array) $this->get('output.formats'), $property, 'name');
399 1
        if (empty($properties)) {
400
            throw new ConfigException(\sprintf('Property "%s" is not defined for format "%s".', $property, $name));
401
        }
402
403 1
        return $properties[$name] ?? null;
404
    }
405
406
    /*
407
     * Assets helpers.
408
     */
409
410
    /**
411
     * Returns asset image widths.
412
     */
413 1
    public function getAssetsImagesWidths(): array
414
    {
415 1
        return $this->get('assets.images.responsive.widths');
416
    }
417
418
    /**
419
     * Returns asset image sizes.
420
     */
421 1
    public function getAssetsImagesSizes(): array
422
    {
423 1
        return $this->get('assets.images.responsive.sizes');
424
    }
425
426
    /*
427
     * Theme helpers.
428
     */
429
430
    /**
431
     * Returns theme(s) as an array.
432
     */
433 1
    public function getTheme(): ?array
434
    {
435 1
        if ($themes = $this->get('theme')) {
436 1
            if (\is_array($themes)) {
437 1
                return $themes;
438
            }
439
440
            return [$themes];
441
        }
442
443
        return null;
444
    }
445
446
    /**
447
     * Has a (valid) theme(s)?
448
     *
449
     * @throws ConfigException
450
     */
451 1
    public function hasTheme(): bool
452
    {
453 1
        if ($themes = $this->getTheme()) {
454 1
            foreach ($themes as $theme) {
455 1
                if (!Util\File::getFS()->exists($this->getThemeDirPath($theme, 'layouts')) && !Util\File::getFS()->exists(Util::joinFile($this->getThemesPath(), $theme, 'config.yml'))) {
456
                    throw new ConfigException(\sprintf('Theme "%s" not found. Did you forgot to install it?', $theme));
457
                }
458
            }
459
460 1
            return true;
461
        }
462
463
        return false;
464
    }
465
466
    /**
467
     * Returns the path of a specific theme's directory.
468
     * ("layouts" by default).
469
     */
470 1
    public function getThemeDirPath(string $theme, string $dir = 'layouts'): string
471
    {
472 1
        return Util::joinFile($this->getThemesPath(), $theme, $dir);
473
    }
474
475
    /*
476
     * Language helpers.
477
     */
478
479
    /**
480
     * Returns an array of available languages.
481
     *
482
     * @throws ConfigException
483
     */
484 1
    public function getLanguages(): array
485
    {
486 1
        if ($this->languages !== null) {
487 1
            return $this->languages;
488
        }
489 1
        $languages = array_filter((array) $this->get('languages'), function ($language) {
490 1
            return !(isset($language['enabled']) && $language['enabled'] === false);
491 1
        });
492 1
        if (!\is_int(array_search($this->getLanguageDefault(), array_column($languages, 'code')))) {
493
            throw new ConfigException(\sprintf('The default language "%s" is not listed in "languages".', $this->getLanguageDefault()));
494
        }
495 1
        $this->languages = $languages;
496
497 1
        return $this->languages;
498
    }
499
500
    /**
501
     * Returns the default language code (ie: "en", "fr-FR", etc.).
502
     *
503
     * @throws ConfigException
504
     */
505 1
    public function getLanguageDefault(): string
506
    {
507 1
        if (!$this->get('language')) {
508
            throw new ConfigException('There is no default "language" key.');
509
        }
510 1
        if (\is_array($this->get('language'))) {
511
            if (!$this->get('language.code')) {
512
                throw new ConfigException('There is no "language.code" key.');
513
            }
514
515
            return $this->get('language.code');
516
        }
517
518 1
        return $this->get('language');
519
    }
520
521
    /**
522
     * Returns a language code index.
523
     *
524
     * @throws ConfigException
525
     */
526 1
    public function getLanguageIndex(string $code): int
527
    {
528 1
        $array = array_column($this->getLanguages(), 'code');
529 1
        if (false === $index = array_search($code, $array)) {
530
            throw new ConfigException(\sprintf('The language code "%s" is not defined.', $code));
531
        }
532
533 1
        return $index;
534
    }
535
536
    /**
537
     * Returns the property value of a (specified or the default) language.
538
     *
539
     * @throws ConfigException
540
     */
541 1
    public function getLanguageProperty(string $property, ?string $code = null): string
542
    {
543 1
        $code = $code ?? $this->getLanguageDefault();
544 1
        $properties = array_column($this->getLanguages(), $property, 'code');
545 1
        if (empty($properties)) {
546
            throw new ConfigException(\sprintf('Property "%s" is not defined for language "%s".', $property, $code));
547
        }
548
549 1
        return $properties[$code];
550
    }
551
552
    /*
553
     * Cache helpers.
554
     */
555
556
    /**
557
     * Is cache dir is absolute to system files
558
     * or relative to project destination?
559
     */
560 1
    public function isCacheDirIsAbsolute(): bool
561
    {
562 1
        $path = (string) $this->get('cache.dir');
563 1
        if (Util::joinFile($path) == realpath(Util::joinFile($path))) {
564
            return true;
565
        }
566
567 1
        return false;
568
    }
569
570
    /*
571
     * Private functions.
572
     */
573
574
    /**
575
     * Set configuration from environment variables starting with "CECIL_".
576
     */
577 1
    private function setFromEnv(): void
578
    {
579 1
        foreach (getenv() as $key => $value) {
580 1
            if (str_starts_with($key, 'CECIL_')) {
581 1
                $this->data->set(str_replace(['cecil_', '_'], ['', '.'], strtolower($key)), $this->castSetValue($value));
582
            }
583
        }
584
    }
585
586
    /**
587
     * Casts boolean value given to set() as string.
588
     *
589
     * @param mixed $value
590
     *
591
     * @return bool|mixed
592
     */
593 1
    private function castSetValue($value)
594
    {
595 1
        $filteredValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
596
597 1
        if ($filteredValue !== null) {
598 1
            return $filteredValue;
599
        }
600
601 1
        return $value;
602
    }
603
604
    /**
605
     * Validate the configuration.
606
     *
607
     * @throws ConfigException
608
     */
609 1
    private function validate(): void
610
    {
611
        // default language must be valid
612 1
        if (!preg_match('/^' . Config::LANG_CODE_PATTERN . '$/', $this->getLanguageDefault())) {
613
            throw new ConfigException(\sprintf('Default language code "%s" is not valid (e.g.: "language: fr-FR").', $this->getLanguageDefault()));
614
        }
615
        // if language is set then the locale is required and must be valid
616 1
        foreach ((array) $this->get('languages') as $lang) {
617 1
            if (!isset($lang['locale'])) {
618
                throw new ConfigException('A language locale is not defined.');
619
            }
620 1
            if (!preg_match('/^' . Config::LANG_LOCALE_PATTERN . '$/', $lang['locale'])) {
621
                throw new ConfigException(\sprintf('The language locale "%s" is not valid (e.g.: "locale: fr_FR").', $lang['locale']));
622
            }
623
        }
624
625
        // check for deprecated options
626 1
        $deprecatedConfigFile = Util\File::getRealPath('../config/deprecated.php');
627 1
        $deprecatedConfig = require $deprecatedConfigFile;
628 1
        array_walk($deprecatedConfig, function ($to, $from) {
629 1
            if ($this->has($from)) {
630
                if (empty($to)) {
631
                    throw new ConfigException("Option `$from` is deprecated and must be removed.");
632
                }
633
                $path = explode(':', $to);
634
                $step = 0;
635
                $formatedPath = '';
636
                foreach ($path as $fragment) {
637
                    $step = $step + 2;
638
                    $formatedPath .= "$fragment:\n" . str_pad(' ', $step);
639
                }
640
                $formatedPath = trim($formatedPath);
641
                throw new ConfigException("Option `$from` must be moved to:\n```\n$formatedPath\n```");
642
            }
643 1
        });
644
    }
645
}
646