Completed
Pull Request — master (#46)
by Vladimir
02:44
created

Configuration::mergeDefaultConfiguration()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 24
ccs 17
cts 17
cp 1
rs 8.9713
c 0
b 0
f 0
cc 1
eloc 16
nc 1
nop 0
crap 1
1
<?php
2
3
/**
4
 * @copyright 2017 Vladimir Jimenez
5
 * @license   https://github.com/allejo/stakx/blob/master/LICENSE.md MIT
6
 */
7
8
namespace allejo\stakx;
9
10
use allejo\stakx\Exception\FileAccessDeniedException;
11
use allejo\stakx\Exception\RecursiveConfigurationException;
12
use allejo\stakx\System\Filesystem;
13
use allejo\stakx\Utilities\ArrayUtilities;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
17
use Symfony\Component\Yaml\Exception\ParseException;
18
use Symfony\Component\Yaml\Yaml;
19
20
class Configuration implements LoggerAwareInterface
21
{
22
    const DEFAULT_NAME = '_config.yml';
23
    const IMPORT_KEYWORD = 'import';
24
    const CACHE_FOLDER = '.stakx-cache';
25
26
    private static $configImports = array();
27
28
    /**
29
     * A list of regular expressions or files directly related to stakx websites that should not be copied over to the
30
     * compiled website as an asset.
31
     *
32
     * @var array
33
     */
34
    public static $stakxSourceFiles = array('/^_(?!themes).*/', '/.twig$/');
35
36
    /**
37
     * An array representation of the main Yaml configuration.
38
     *
39
     * @var array
40
     */
41
    private $configuration;
42
43
    /**
44
     * @var string
45
     */
46
    private $parentConfig;
47
48
    /** @var string */
49
    private $currentFile;
50
51
    /**
52
     * @var LoggerInterface
53
     */
54
    private $output;
55
56
    /**
57
     * @var Filesystem
58
     */
59
    private $fs;
0 ignored issues
show
Comprehensibility introduced by
Avoid variables with short names like $fs. Configured minimum length is 3.

Short variable names may make your code harder to understand. Variable names should be self-descriptive. This check looks for variable names who are shorter than a configured minimum.

Loading history...
60
61
    /**
62
     * Configuration constructor.
63
     */
64 50
    public function __construct()
65
    {
66 50
        $this->configuration = array();
67 50
        $this->fs = new Filesystem();
68 50
    }
69
70
    /**
71
     * {@inheritdoc}
72
     */
73 50
    public function setLogger(LoggerInterface $logger)
74
    {
75 50
        $this->output = $logger;
76 50
    }
77
78
    ///
79
    // Getters
80
    ///
81
82
    /**
83
     * @return bool
84
     */
85 15
    public function isDebug()
86
    {
87 15
        return $this->returnConfigOption('debug', false);
88
    }
89
90
    /**
91
     * @TODO 1.0.0 Remove support for 'base' in next major release; it has been replaced by 'baseurl'
92
     *
93
     * @return mixed|null
94
     */
95 4
    public function getBaseUrl()
96
    {
97 4
        $base = $this->returnConfigOption('base');
98 4
        $baseUrl = $this->returnConfigOption('baseurl');
99
100 4
        if (is_null($base) || (!empty($baseUrl)))
101 4
        {
102 3
            return $baseUrl;
103
        }
104
105 1
        return $base;
106
    }
107
108
    /**
109
     * @return string[]
110
     */
111 1
    public function getDataFolders()
112
    {
113 1
        return $this->returnConfigOption('data');
114
    }
115
116
    /**
117
     * @return string[]
118
     */
119 1
    public function getDataSets()
120
    {
121 1
        return $this->returnConfigOption('datasets');
122
    }
123
124
    /**
125
     * @return string[]
126
     */
127 1
    public function getIncludes()
128
    {
129 1
        return $this->returnConfigOption('include', array());
130
    }
131
132
    /**
133
     * @return string[]
134
     */
135 1
    public function getExcludes()
136
    {
137 1
        return $this->returnConfigOption('exclude', array());
138
    }
139
140
    /**
141
     * @return string
142
     */
143 15
    public function getTheme()
144
    {
145 15
        return $this->returnConfigOption('theme');
146
    }
147
148
    /**
149
     * @return array
150
     */
151 7
    public function getConfiguration()
152
    {
153 7
        return $this->configuration;
154
    }
155
156
    /**
157
     * @return string[]
158
     */
159 1
    public function getPageViewFolders()
160
    {
161 1
        return $this->returnConfigOption('pageviews');
162
    }
163
164
    /**
165
     * @return string
166
     */
167 2
    public function getTargetFolder()
168
    {
169 2
        return $this->returnConfigOption('target');
170
    }
171
172
    /**
173
     * @return string[][]
174
     */
175 1
    public function getCollectionsFolders()
176
    {
177 1
        return $this->returnConfigOption('collections');
178
    }
179
180
    /**
181
     * @return bool
182
     */
183 15
    public function getTwigAutoescape()
0 ignored issues
show
Coding Style introduced by
function getTwigAutoescape() does not seem to conform to the naming convention (^(?:is|has|should|may|supports)).

This check examines a number of code elements and verifies that they conform to the given naming conventions.

You can set conventions for local variables, abstract classes, utility classes, constant, properties, methods, parameters, interfaces, classes, exceptions and special methods.

Loading history...
184
    {
185 15
        return $this->configuration['twig']['autoescape'];
186
    }
187
188
    /**
189
     * @return false|string
190
     */
191
    public function getRedirectTemplate()
192
    {
193
        return $this->configuration['templates']['redirect'];
194
    }
195
196
    /**
197
     * Return the specified configuration option if available, otherwise return the default.
198
     *
199
     * @param string     $name    The configuration option to lookup
200
     * @param mixed|null $default The default value returned if the configuration option isn't found
201
     *
202
     * @return mixed|null
203
     */
204 50
    private function returnConfigOption($name, $default = null)
205
    {
206 50
        return isset($this->configuration[$name]) ? $this->configuration[$name] : $default;
207 1
    }
208
209
    ///
210
    // Parsing
211
    ///
212
213
    /**
214
     * Safely read a YAML configuration file and return an array representation of it.
215
     *
216
     * This function will only read files from within the website folder.
217
     *
218
     * @param  string $filePath
219
     *
220
     * @return array
0 ignored issues
show
Documentation introduced by
Should the return type not be string|array|\stdClass?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
221
     */
222 36
    private static function readFile($filePath)
223
    {
224 36
        $fs = new Filesystem();
0 ignored issues
show
Comprehensibility introduced by
Avoid variables with short names like $fs. Configured minimum length is 3.

Short variable names may make your code harder to understand. Variable names should be self-descriptive. This check looks for variable names who are shorter than a configured minimum.

Loading history...
225 36
        $fileRaw = $fs->safeReadFile($filePath);
226 36
        $parsed = Yaml::parse($fileRaw);
227
228 36
        return (null === $parsed) ? array() : $parsed;
229
    }
230
231
    /**
232
     * Parse a configuration file.
233
     *
234
     * @param string|null $configFile
235
     */
236 50
    public function parse($configFile = null)
237
    {
238 50
        $this->parentConfig = $this->fs->getRelativePath($configFile);
239 50
        self::$configImports = array();
240
241 50
        $this->configuration = $this->parseConfig($configFile);
242 50
        $this->mergeDefaultConfiguration();
243 50
        $this->handleDeprecations();
244
245 50
        self::$configImports = array();
246 50
    }
247
248
    /**
249
     * Parse a given configuration file and return an associative array representation.
250
     *
251
     * This function will automatically take care of imports in each file, whether it be a child or grandchild config
252
     * file. `$configFile` should be called with 'null' when "configuration-less" mode is used.
253
     *
254
     * @param string|null $configFile     The path to the configuration file. If null, the default configuration will be
255
     *                                    used
256
     *
257
     * @return array
258
     */
259 50
    private function parseConfig($configFile = null)
260
    {
261 50
        if (null === $configFile)
262 50
        {
263 50
            return array();
264
        }
265
266 36
        $this->currentFile = $configFile;
267
268
        try
269
        {
270 36
            $this->isRecursiveImport($configFile);
271
272 36
            $parsedConfig = self::readFile($configFile);
273
274 36
            $this->handleImports($parsedConfig);
275
276 36
            unset($parsedConfig[self::IMPORT_KEYWORD]);
277 36
            return $parsedConfig;
278
        }
279 3
        catch (ParseException $e)
280
        {
281 1
            $this->output->error('{file}: parsing failed... {message}', array(
282 1
                'message' => $e->getMessage(),
283 1
                'file' => $configFile,
284 1
            ));
285 1
            $this->output->error('Using default configuration...');
286 1
        }
287 2
        catch (RecursiveConfigurationException $e)
288
        {
289 1
            $this->output->error("{file}: you can't recursively import a file that's already been imported: {import}", array(
290 1
                'file' => $configFile,
291 1
                'import' => $e->getRecursiveImport(),
292 1
            ));
293
        }
294
295 2
        return array();
296
    }
297
298
    /**
299
     * Merge the default configuration with the parsed configuration.
300
     */
301 50
    private function mergeDefaultConfiguration()
302
    {
303
        $defaultConfig = array(
304 50
            'baseurl'   => '',
305 50
            'target'    => '_site',
306
            'twig'      => array(
307 50
                'autoescape' => false,
308 50
            ),
309
            'include'   => array(
310 50
                '.htaccess',
311 50
            ),
312
            'exclude'   => array(
313 50
                'node_modules/',
314 50
                'stakx-theme.yml',
315 50
                '/tmp___$/',
316 50
                self::DEFAULT_NAME,
317 50
            ),
318
            'templates' => array(
319 50
                'redirect' => false,
320 50
            ),
321 50
        );
322
323 50
        $this->configuration = ArrayUtilities::array_merge_defaults($defaultConfig, $this->configuration, 'name');
324 50
    }
325
326
    /**
327
     * Warn about deprecated keywords in the configuration file.
328
     */
329 50
    private function handleDeprecations()
330
    {
331
        // @TODO 1.0.0 handle 'base' deprecation in _config.yml
332 50
        $base = $this->returnConfigOption('base');
333
334 50
        if (!is_null($base))
335 50
        {
336 2
            $this->output->warning("The 'base' configuration option has been replaced by 'baseurl' and will be removed in in version 1.0.0.");
337 2
        }
338 50
    }
339
340
    /**
341
     * Recursively resolve imports for a given array.
342
     *
343
     * This modifies the array in place.
344
     *
345
     * @param array $configuration
346
     */
347 36
    private function handleImports(array &$configuration)
348
    {
349 36
        if (!array_key_exists(self::IMPORT_KEYWORD, $configuration))
350 36
        {
351 36
            $this->output->debug('{file}: does not import any other files', array(
352 36
                'file' => $this->parentConfig,
353 36
            ));
354
355 36
            return;
356
        }
357
358 19
        if (!is_array($imports = $configuration[self::IMPORT_KEYWORD]))
359 19
        {
360 4
            $this->output->error('{file}: the reserved "import" keyword can only be an array');
361
362 4
            return;
363
        }
364
365 15
        $parentConfigLocation = $this->fs->getFolderPath($this->parentConfig);
366
367 15
        foreach ($imports as $import)
368
        {
369 15
            $this->handleImport($import, $parentConfigLocation, $configuration);
370 15
        }
371 15
    }
372
373
    /**
374
     * Resolve a single import definition.
375
     *
376
     * @param string $importDef     The path for a given import; this will be treated as a relative path to the parent
377
     *                              configuration
378
     * @param string $parentConfLoc The path to the parent configuration
379
     * @param array  $configuration The array representation of the current configuration; this will be modified in place
380
     */
381 15
    private function handleImport($importDef, $parentConfLoc, array &$configuration)
382
    {
383 15
        if (!is_string($importDef))
384 15
        {
385 3
            $this->output->error('{file}: invalid import: {message}', array(
386 3
                'file' => $this->parentConfig,
387 3
                'message' => $importDef,
388 3
            ));
389
390 3
            return;
391
        }
392
393 12
        $import = $this->fs->appendPath($parentConfLoc, $importDef);
394
395 12
        if (!$this->isValidImport($import))
396 12
        {
397 4
            return;
398
        }
399
400 8
        $this->output->debug('{file}: imports additional file: {import}', array(
401 8
            'file' => $this->parentConfig,
402 8
            'import' => $import,
403 8
        ));
404
405
        try
406
        {
407 8
            $importedConfig = $this->parseConfig($import);
408 7
            $configuration = $this->mergeImports($importedConfig, $configuration);
409
        }
410 8
        catch (FileAccessDeniedException $e)
411
        {
412
            $this->output->warning('{file}: trying access file outside of project directory: {import}', array(
413
                'file' => $this->parentConfig,
414
                'import' => $import,
415
            ));
416
        }
417 1
        catch (FileNotFoundException $e)
418
        {
419 1
            $this->output->warning('{file}: could not find file to import: {import}', array(
420 1
                'file' => $this->parentConfig,
421 1
                'import' => $import,
422 1
            ));
423
        }
424 8
    }
425
426
    /**
427
     * Check whether a given file path is a valid import.
428
     *
429
     * @param  string $filePath
430
     *
431
     * @return bool
432
     */
433 12
    private function isValidImport($filePath)
434
    {
435 12
        $errorMsg = '';
436
437 12
        if ($this->fs->isDir($filePath))
438 12
        {
439 1
            $errorMsg = 'a directory';
440 1
        }
441 11
        elseif ($this->fs->isSymlink($filePath))
442
        {
443 1
            $errorMsg = 'a symbolically linked file';
444 1
        }
445 10
        elseif ($this->fs->absolutePath($this->currentFile) == $this->fs->absolutePath($filePath))
446
        {
447 1
            $errorMsg = 'yourself';
448 1
        }
449 9
        elseif (($ext = $this->fs->getExtension($filePath)) != 'yml' && $ext != 'yaml')
450
        {
451 1
            $errorMsg = 'a non-YAML configuration';
452 1
        }
453
454 12
        if (!($noErrors = empty($errorMsg)))
455 12
        {
456 4
            $this->output->error("{file}: you can't import {message}: {import}", array(
457 4
                'file' => $this->parentConfig,
458 4
                'message' => $errorMsg,
459
                'import' => $filePath
460 4
            ));
461 4
        }
462
463 12
        return $noErrors;
464
    }
465
466
    /**
467
     * Check whether or not a filename has already been imported in a given process.
468
     *
469
     * @param string $filePath
470
     */
471 36
    private function isRecursiveImport($filePath)
472
    {
473 36
        if (in_array($filePath, self::$configImports))
474 36
        {
475 1
            throw new RecursiveConfigurationException($filePath, sprintf(
476 1
                'The %s file has already been imported', $filePath
477 1
            ));
478
        }
479
480 36
        self::$configImports[] = $filePath;
481 36
    }
482
483
    /**
484
     * Merge the given array with existing configuration.
485
     *
486
     * @param  array $importedConfig
487
     * @param  array $existingConfig
488
     *
489
     * @return array
490
     */
491 7
    private function mergeImports(array $importedConfig, array $existingConfig)
492
    {
493 7
        $arraySplit = ArrayUtilities::associative_array_split(self::IMPORT_KEYWORD, $existingConfig, false);
494 7
        $beforeImport = ArrayUtilities::array_merge_defaults($arraySplit[0], $importedConfig, 'name');
495 7
        $result = ArrayUtilities::array_merge_defaults($beforeImport, $arraySplit[1], 'name');
496
497 7
        return $result;
498
    }
499
}
500