Completed
Pull Request — master (#46)
by Vladimir
13:24 queued 03:28
created

Configuration::mergeDefaultConfiguration()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 24
ccs 15
cts 15
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 50
     */
64
    public function __construct()
65 50
    {
66 50
        $this->configuration = array();
67 50
        $this->fs = new Filesystem();
68
    }
69
70
    /**
71
     * {@inheritdoc}
72 50
     */
73
    public function setLogger(LoggerInterface $logger)
74 50
    {
75 50
        $this->output = $logger;
76
    }
77
78
    ///
79
    // Getters
80
    ///
81
82
    /**
83
     * @return bool
84 15
     */
85
    public function isDebug()
86 15
    {
87
        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 4
     */
95
    public function getBaseUrl()
96 4
    {
97 4
        $base = $this->returnConfigOption('base');
98
        $baseUrl = $this->returnConfigOption('baseurl');
99 4
100 4
        if (is_null($base) || (!empty($baseUrl)))
101 3
        {
102
            return $baseUrl;
103
        }
104 1
105
        return $base;
106
    }
107
108
    /**
109
     * @return string[]
110 1
     */
111
    public function getDataFolders()
112 1
    {
113
        return $this->returnConfigOption('data');
114
    }
115
116
    /**
117
     * @return string[]
118 1
     */
119
    public function getDataSets()
120 1
    {
121
        return $this->returnConfigOption('datasets');
122
    }
123
124
    /**
125
     * @return string[]
126 1
     */
127
    public function getIncludes()
128 1
    {
129
        return $this->returnConfigOption('include', array());
130
    }
131
132
    /**
133
     * @return string[]
134 1
     */
135
    public function getExcludes()
136 1
    {
137
        return $this->returnConfigOption('exclude', array());
138
    }
139
140
    /**
141
     * @return string
142 15
     */
143
    public function getTheme()
144 15
    {
145
        return $this->returnConfigOption('theme');
146
    }
147
148
    /**
149
     * @return array
150 7
     */
151
    public function getConfiguration()
152 7
    {
153
        return $this->configuration;
154
    }
155
156
    /**
157
     * @return string[]
158 1
     */
159
    public function getPageViewFolders()
160 1
    {
161
        return $this->returnConfigOption('pageviews');
162
    }
163
164
    /**
165
     * @return string
166 2
     */
167
    public function getTargetFolder()
168 2
    {
169
        return $this->returnConfigOption('target');
170
    }
171
172
    /**
173
     * @return string[][]
174 1
     */
175
    public function getCollectionsFolders()
176 1
    {
177
        return $this->returnConfigOption('collections');
178
    }
179
180
    /**
181
     * @return bool
182 15
     */
183
    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 15
    {
185
        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 50
     */
204
    private function returnConfigOption($name, $default = null)
205 50
    {
206
        return isset($this->configuration[$name]) ? $this->configuration[$name] : $default;
207
    }
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 36
     */
222
    private static function readFile($filePath)
223 36
    {
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
        $parsed = Yaml::parse($fileRaw);
227 36
228
        return (null === $parsed) ? array() : $parsed;
229
    }
230
231
    /**
232
     * Parse a configuration file.
233
     *
234
     * @param string|null $configFile
235 50
     */
236
    public function parse($configFile = null)
237 50
    {
238 50
        $this->parentConfig = $this->fs->getRelativePath($configFile);
239
        self::$configImports = array();
240 50
241 50
        $this->configuration = $this->parseConfig($configFile);
242 50
        $this->mergeDefaultConfiguration();
243
        $this->handleDeprecations();
244 50
245 50
        self::$configImports = array();
246
    }
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 50
     */
259
    private function parseConfig($configFile = null)
260 50
    {
261 50
        if (null === $configFile)
262 50
        {
263
            return array();
264
        }
265 36
266
        $this->currentFile = $configFile;
267
268
        try
269 36
        {
270
            $this->isRecursiveImport($configFile);
271 36
272
            $parsedConfig = self::readFile($configFile);
273 36
274
            $this->handleImports($parsedConfig);
275 36
276 36
            unset($parsedConfig[self::IMPORT_KEYWORD]);
277
            return $parsedConfig;
278 3
        }
279
        catch (ParseException $e)
280 1
        {
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 2
        }
287
        catch (RecursiveConfigurationException $e)
288 1
        {
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
            ));
293
        }
294 2
295
        return array();
296
    }
297
298
    /**
299
     * Merge the default configuration with the parsed configuration.
300 50
     */
301
    private function mergeDefaultConfiguration()
302
    {
303 50
        $defaultConfig = array(
304 50
            'baseurl'   => '',
305
            'target'    => '_site',
306 50
            'twig'      => array(
307 50
                'autoescape' => false,
308
            ),
309 50
            'include'   => array(
310 50
                '.htaccess',
311
            ),
312 50
            'exclude'   => array(
313 50
                'node_modules/',
314 50
                'stakx-theme.yml',
315 50
                '/tmp___$/',
316
                self::DEFAULT_NAME,
317 50
            ),
318 50
            'templates' => array(
319 50
                'redirect' => false,
320
            ),
321 50
        );
322 50
323
        $this->configuration = ArrayUtilities::array_merge_defaults($defaultConfig, $this->configuration, 'name');
324
    }
325
326
    /**
327 50
     * Warn about deprecated keywords in the configuration file.
328
     */
329
    private function handleDeprecations()
330 50
    {
331
        // @TODO 1.0.0 handle 'base' deprecation in _config.yml
332 50
        $base = $this->returnConfigOption('base');
333 50
334 2
        if (!is_null($base))
335 2
        {
336 50
            $this->output->warning("The 'base' configuration option has been replaced by 'baseurl' and will be removed in in version 1.0.0.");
337
        }
338
    }
339
340
    /**
341
     * Recursively resolve imports for a given array.
342
     *
343
     * This modifies the array in place.
344
     *
345 36
     * @param array $configuration
346
     */
347 36
    private function handleImports(array &$configuration)
348 36
    {
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
                'file' => $this->parentConfig,
353 36
            ));
354
355
            return;
356 19
        }
357 19
358 4
        if (!is_array($imports = $configuration[self::IMPORT_KEYWORD]))
359
        {
360 4
            $this->output->error('{file}: the reserved "import" keyword can only be an array');
361
362
            return;
363 15
        }
364
365 15
        $parentConfigLocation = $this->fs->getFolderPath($this->parentConfig);
366
367 15
        foreach ($imports as $import)
368 15
        {
369 15
            $this->handleImport($import, $parentConfigLocation, $configuration);
370
        }
371
    }
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 15
     * @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 15
    {
383 3
        if (!is_string($importDef))
384 3
        {
385 3
            $this->output->error('{file}: invalid import: {message}', array(
386 3
                'file' => $this->parentConfig,
387
                'message' => $importDef,
388 3
            ));
389
390
            return;
391 12
        }
392
393 12
        $import = $this->fs->appendPath($parentConfLoc, $importDef);
394 12
395 4
        if (!$this->isValidImport($import))
396
        {
397
            return;
398 8
        }
399 8
400 8
        $this->output->debug('{file}: imports additional file: {import}', array(
401 8
            'file' => $this->parentConfig,
402
            'import' => $import,
403
        ));
404
405 8
        try
406 7
        {
407
            $importedConfig = $this->parseConfig($import);
408 8
            $configuration = $this->mergeImports($importedConfig, $configuration);
409
        }
410
        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 1
            ));
416
        }
417 1
        catch (FileNotFoundException $e)
418 1
        {
419 1
            $this->output->warning('{file}: could not find file to import: {import}', array(
420 1
                'file' => $this->parentConfig,
421
                'import' => $import,
422 8
            ));
423
        }
424
    }
425
426
    /**
427
     * Check whether a given file path is a valid import.
428
     *
429
     * @param  string $filePath
430
     *
431 12
     * @return bool
432
     */
433 12
    private function isValidImport($filePath)
434
    {
435 12
        $errorMsg = '';
436 12
437 1
        if ($this->fs->isDir($filePath))
438 1
        {
439 11
            $errorMsg = 'a directory';
440
        }
441 1
        elseif ($this->fs->isSymlink($filePath))
442 1
        {
443 10
            $errorMsg = 'a symbolically linked file';
444
        }
445 1
        elseif ($this->fs->absolutePath($this->currentFile) == $this->fs->absolutePath($filePath))
446 1
        {
447 9
            $errorMsg = 'yourself';
448
        }
449 1
        elseif (($ext = $this->fs->getExtension($filePath)) != 'yml' && $ext != 'yaml')
450 1
        {
451
            $errorMsg = 'a non-YAML configuration';
452 12
        }
453 12
454 4
        if (!($noErrors = empty($errorMsg)))
455 4
        {
456 4
            $this->output->error("{file}: you can't import {message}: {import}", array(
457
                'file' => $this->parentConfig,
458 4
                'message' => $errorMsg,
459 4
                'import' => $filePath
460
            ));
461 12
        }
462
463
        return $noErrors;
464
    }
465
466
    /**
467
     * Check whether or not a filename has already been imported in a given process.
468
     *
469 36
     * @param string $filePath
470
     */
471 36
    private function isRecursiveImport($filePath)
472 36
    {
473 1
        if (in_array($filePath, self::$configImports))
474 1
        {
475 1
            throw new RecursiveConfigurationException($filePath, sprintf(
476
                'The %s file has already been imported', $filePath
477
            ));
478 36
        }
479 36
480
        self::$configImports[] = $filePath;
481
    }
482
483
    /**
484
     * Merge the given array with existing configuration.
485
     *
486
     * @param  array $importedConfig
487
     * @param  array $existingConfig
488
     *
489 7
     * @return array
490
     */
491 7
    private function mergeImports(array $importedConfig, array $existingConfig)
492 7
    {
493 7
        $arraySplit = ArrayUtilities::associative_array_split(self::IMPORT_KEYWORD, $existingConfig, false);
494
        $beforeImport = ArrayUtilities::array_merge_defaults($arraySplit[0], $importedConfig, 'name');
495 7
        $result = ArrayUtilities::array_merge_defaults($beforeImport, $arraySplit[1], 'name');
496
497
        return $result;
498
    }
499
}
500