Completed
Pull Request — master (#42)
by Vladimir
02:38
created

Configuration::parseConfiguration()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3.2621

Importance

Changes 0
Metric Value
dl 0
loc 20
ccs 9
cts 13
cp 0.6923
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 10
nc 3
nop 1
crap 3.2621

1 Method

Rating   Name   Duplication   Size   Complexity  
A Configuration::setLogger() 0 4 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
25
    private static $configImports = array();
26
27
    /**
28
     * A list of regular expressions or files directly related to stakx websites that should not be copied over to the
29
     * compiled website as an asset.
30
     *
31
     * @var array
32
     */
33
    public static $stakxSourceFiles = array('/^_(?!themes).*/', '/.twig$/');
34
35
    /**
36
     * An array representation of the main Yaml configuration.
37
     *
38
     * @var array
39
     */
40
    private $configuration;
41
42
    /**
43
     * @var string
44
     */
45
    private $parentConfig;
46
47
    /** @var string */
48
    private $currentFile;
49
50
    /**
51
     * @var LoggerInterface
52
     */
53
    private $output;
54
55
    /**
56
     * @var Filesystem
57
     */
58
    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...
59
60
    /**
61
     * Configuration constructor.
62
     */
63 46
    public function __construct()
64
    {
65 46
        $this->configuration = array();
66 46
        $this->fs = new Filesystem();
67 46
    }
68
69
    /**
70
     * {@inheritdoc}
71
     */
72 46
    public function setLogger(LoggerInterface $logger)
73
    {
74 46
        $this->output = $logger;
75 46
    }
76
77
    ///
78
    // Getters
79
    ///
80
81
    /**
82
     * @return bool
83
     */
84 14
    public function isDebug()
85
    {
86 14
        return $this->returnConfigOption('debug', false);
87
    }
88
89
    /**
90
     * @TODO 1.0.0 Remove support for 'base' in next major release; it has been replaced by 'baseurl'
91
     *
92
     * @return mixed|null
93
     */
94 4
    public function getBaseUrl()
95
    {
96 4
        $base = $this->returnConfigOption('base');
97 4
        $baseUrl = $this->returnConfigOption('baseurl');
98
99 4
        if (is_null($base) || (!empty($baseUrl)))
100
        {
101 3
            return $baseUrl;
102
        }
103
104 1
        return $base;
105
    }
106
107
    /**
108
     * @return string[]
109
     */
110 1
    public function getDataFolders()
111
    {
112 1
        return $this->returnConfigOption('data');
113
    }
114
115
    /**
116
     * @return string[]
117
     */
118 1
    public function getDataSets()
119
    {
120 1
        return $this->returnConfigOption('datasets');
121
    }
122
123
    /**
124
     * @return string[]
125
     */
126 1
    public function getIncludes()
127
    {
128 1
        return $this->returnConfigOption('include', array());
129
    }
130
131
    /**
132
     * @return string[]
133
     */
134 1
    public function getExcludes()
135
    {
136 1
        return $this->returnConfigOption('exclude', array());
137
    }
138
139
    /**
140
     * @return string
141
     */
142 14
    public function getTheme()
143
    {
144 14
        return $this->returnConfigOption('theme');
145
    }
146
147
    /**
148
     * @return array
149
     */
150 7
    public function getConfiguration()
151
    {
152 7
        return $this->configuration;
153
    }
154
155
    /**
156
     * @return string[]
157
     */
158 1
    public function getPageViewFolders()
159
    {
160 1
        return $this->returnConfigOption('pageviews');
161
    }
162
163
    /**
164
     * @return string
165
     */
166 2
    public function getTargetFolder()
167
    {
168 2
        return $this->returnConfigOption('target');
169
    }
170
171
    /**
172
     * @return string[][]
173
     */
174 1
    public function getCollectionsFolders()
175
    {
176 1
        return $this->returnConfigOption('collections');
177
    }
178
179
    /**
180
     * @return bool
181
     */
182 14
    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...
183
    {
184 14
        return $this->configuration['twig']['autoescape'];
185
    }
186
187
    /**
188
     * @return false|string
189
     */
190
    public function getRedirectTemplate()
191
    {
192
        return $this->configuration['templates']['redirect'];
193
    }
194
195
    /**
196
     * Return the specified configuration option if available, otherwise return the default.
197
     *
198
     * @param string     $name    The configuration option to lookup
199
     * @param mixed|null $default The default value returned if the configuration option isn't found
200
     *
201
     * @return mixed|null
202
     */
203 46
    private function returnConfigOption($name, $default = null)
204
    {
205 46
        return isset($this->configuration[$name]) ? $this->configuration[$name] : $default;
206
    }
207
208
    ///
209
    // Parsing
210
    ///
211
212
    /**
213
     * Safely read a YAML configuration file and return an array representation of it.
214
     *
215
     * This function will only read files from within the website folder.
216
     *
217
     * @param  string $filePath
218
     *
219
     * @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...
220
     */
221 33
    private static function readFile($filePath)
222
    {
223 33
        $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...
224 33
        $fileRaw = $fs->safeReadFile($filePath);
225 33
        $parsed = Yaml::parse($fileRaw);
226
227 33
        return (null === $parsed) ? array() : $parsed;
228
    }
229
230
    /**
231
     * Parse a configuration file.
232
     *
233
     * @param string|null $configFile
234
     */
235 46
    public function parse($configFile = null)
236
    {
237 46
        $this->parentConfig = $this->fs->getRelativePath($configFile);
238 46
        self::$configImports = array();
239
240 46
        $this->configuration = $this->parseConfig($configFile);
241 46
        $this->mergeDefaultConfiguration();
242 46
        $this->handleDeprecations();
243
244 46
        self::$configImports = array();
245 46
    }
246
247
    /**
248
     * Parse a given configuration file and return an associative array representation.
249
     *
250
     * This function will automatically take care of imports in each file, whether it be a child or grandchild config
251
     * file. `$configFile` should be called with 'null' when "configuration-less" mode is used.
252
     *
253
     * @param string|null $configFile     The path to the configuration file. If null, the default configuration will be
254
     *                                    used
255
     *
256
     * @return array
257
     */
258 46
    private function parseConfig($configFile = null)
259
    {
260 46
        if (null === $configFile)
261
        {
262 46
            return array();
263
        }
264
265 33
        $this->currentFile = $configFile;
266
267
        try
268
        {
269 33
            $this->isRecursiveImport($configFile);
270
271 33
            $parsedConfig = self::readFile($configFile);
272
273 33
            $this->handleImports($parsedConfig);
274
275 33
            unset($parsedConfig[self::IMPORT_KEYWORD]);
276 33
            return $parsedConfig;
277
        }
278 3
        catch (ParseException $e)
279
        {
280 1
            $this->output->error('{file}: parsing failed... {message}', array(
281 1
                'message' => $e->getMessage(),
282 1
                'file' => $configFile,
283
            ));
284 1
            $this->output->error('Using default configuration...');
285
        }
286 2
        catch (RecursiveConfigurationException $e)
287
        {
288 1
            $this->output->error("{file}: you can't recursively import a file that's already been imported: {import}", array(
289 1
                'file' => $configFile,
290 1
                'import' => $e->getRecursiveImport(),
291
            ));
292
        }
293
294 2
        return array();
295
    }
296
297
    /**
298
     * Merge the default configuration with the parsed configuration.
299
     */
300 46
    private function mergeDefaultConfiguration()
301
    {
302
        $defaultConfig = array(
303 46
            'baseurl'   => '',
304 46
            'target'    => '_site',
305
            'twig'      => array(
306
                'autoescape' => false,
307
            ),
308
            'include'   => array(
309
                '.htaccess',
310
            ),
311
            'exclude'   => array(
312 46
                'node_modules/',
313 46
                'stakx-theme.yml',
314 46
                self::DEFAULT_NAME,
315
            ),
316
            'templates' => array(
317
                'redirect' => false,
318
            ),
319
        );
320
321 46
        $this->configuration = ArrayUtilities::array_merge_defaults($defaultConfig, $this->configuration, 'name');
322 46
    }
323
324
    /**
325
     * Warn about deprecated keywords in the configuration file.
326
     */
327 46
    private function handleDeprecations()
328
    {
329
        // @TODO 1.0.0 handle 'base' deprecation in _config.yml
330 46
        $base = $this->returnConfigOption('base');
331
332 46
        if (!is_null($base))
333
        {
334 2
            $this->output->warning("The 'base' configuration option has been replaced by 'baseurl' and will be removed in in version 1.0.0.");
335
        }
336 46
    }
337
338
    /**
339
     * Recursively resolve imports for a given array.
340
     *
341
     * This modifies the array in place.
342
     *
343
     * @param array $configuration
344
     */
345 33
    private function handleImports(array &$configuration)
346
    {
347 33
        if (!array_key_exists(self::IMPORT_KEYWORD, $configuration))
348
        {
349 33
            $this->output->debug('{file}: does not import any other files', array(
350 33
                'file' => $this->parentConfig,
351
            ));
352
353 33
            return;
354
        }
355
356 16
        if (!is_array($imports = $configuration[self::IMPORT_KEYWORD]))
357
        {
358 4
            $this->output->error('{file}: the reserved "import" keyword can only be an array');
359
360 4
            return;
361
        }
362
363 12
        $parentConfigLocation = $this->fs->getFolderPath($this->parentConfig);
364
365 12
        foreach ($imports as $import)
366
        {
367 12
            $this->handleImport($import, $parentConfigLocation, $configuration);
368
        }
369 12
    }
370
371
    /**
372
     * Resolve a single import definition.
373
     *
374
     * @param string $importDef     The path for a given import; this will be treated as a relative path to the parent
375
     *                              configuration
376
     * @param string $parentConfLoc The path to the parent configuration
377
     * @param array  $configuration The array representation of the current configuration; this will be modified in place
378
     */
379 12
    private function handleImport($importDef, $parentConfLoc, array &$configuration)
380
    {
381 12
        if (!is_string($importDef))
382
        {
383
            $this->output->error('{file}: invalid import: {message}', array(
384
                'file' => $this->parentConfig,
385
                'message' => $importDef,
386
            ));
387
388
            return;
389
        }
390
391 12
        $import = $this->fs->appendPath($parentConfLoc, $importDef);
392
393 12
        if (!$this->isValidImport($import))
394
        {
395 4
            return;
396
        }
397
398 8
        $this->output->debug('{file}: imports additional file: {import}', array(
399 8
            'file' => $this->parentConfig,
400 8
            'import' => $import,
401
        ));
402
403
        try
404
        {
405 8
            $importedConfig = $this->parseConfig($import);
406 7
            $configuration = $this->mergeImports($importedConfig, $configuration);
407
        }
408 1
        catch (FileAccessDeniedException $e)
409
        {
410
            $this->output->warning('{file}: trying access file outside of project directory: {import}', array(
411
                'file' => $this->parentConfig,
412
                'import' => $import,
413
            ));
414
        }
415 1
        catch (FileNotFoundException $e)
416
        {
417 1
            $this->output->warning('{file}: could not find file to import: {import}', array(
418 1
                'file' => $this->parentConfig,
419 1
                'import' => $import,
420
            ));
421
        }
422 8
    }
423
424
    /**
425
     * Check whether a given file path is a valid import.
426
     *
427
     * @param  string $filePath
428
     *
429
     * @return bool
430
     */
431 12
    private function isValidImport($filePath)
432
    {
433 12
        $errorMsg = '';
434
435 12
        if ($this->fs->isDir($filePath))
436
        {
437 1
            $errorMsg = 'a directory';
438
        }
439 11
        elseif ($this->fs->isSymlink($filePath))
440
        {
441 1
            $errorMsg = 'a symbolically linked file';
442
        }
443 10
        elseif ($this->fs->absolutePath($this->currentFile) == $this->fs->absolutePath($filePath))
444
        {
445 1
            $errorMsg = 'yourself';
446
        }
447 9
        elseif (($ext = $this->fs->getExtension($filePath)) != 'yml' && $ext != 'yaml')
448
        {
449 1
            $errorMsg = 'a non-YAML configuration';
450
        }
451
452 12
        if (!($noErrors = empty($errorMsg)))
453
        {
454 4
            $this->output->error("{file}: you can't import {message}: {import}", array(
455 4
                'file' => $this->parentConfig,
456 4
                'message' => $errorMsg,
457 4
                'import' => $filePath
458
            ));
459
        }
460
461 12
        return $noErrors;
462
    }
463
464
    /**
465
     * Check whether or not a filename has already been imported in a given process.
466
     *
467
     * @param string $filePath
468
     */
469 33
    private function isRecursiveImport($filePath)
470
    {
471 33
        if (in_array($filePath, self::$configImports))
472
        {
473 1
            throw new RecursiveConfigurationException($filePath, sprintf(
474 1
                'The %s file has already been imported', $filePath
475
            ));
476
        }
477
478 33
        self::$configImports[] = $filePath;
479 33
    }
480
481
    /**
482
     * Merge the given array with existing configuration.
483
     *
484
     * @param  array $importedConfig
485
     * @param  array $existingConfig
486
     *
487
     * @return array
488
     */
489 7
    private function mergeImports(array $importedConfig, array $existingConfig)
490
    {
491 7
        $arraySplit = ArrayUtilities::associative_array_split(self::IMPORT_KEYWORD, $existingConfig, false);
492 7
        $beforeImport = ArrayUtilities::array_merge_defaults($arraySplit[0], $importedConfig, 'name');
493 7
        $result = ArrayUtilities::array_merge_defaults($beforeImport, $arraySplit[1], 'name');
494
495 7
        return $result;
496
    }
497
}
498