Completed
Pull Request — master (#42)
by Vladimir
09:05
created

Configuration::parse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 1
dl 0
loc 11
ccs 0
cts 0
cp 0
crap 2
rs 9.4285
c 0
b 0
f 0
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 30
50
    /**
51 30
     * @var LoggerInterface
52 30
     */
53 30
    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 30
    public function __construct()
64
    {
65 30
        $this->configuration = array();
66
        $this->fs = new Filesystem();
67
    }
68
69 17
    /**
70
     * {@inheritdoc}
71
     */
72
    public function setLogger(LoggerInterface $logger)
73
    {
74
        $this->output = $logger;
75
    }
76
77
    ///
78
    // Getters
79
    ///
80 30
81 30
    /**
82 30
     * @return bool
83
     */
84
    public function isDebug()
85
    {
86
        return $this->returnConfigOption('debug', false);
87 17
    }
88
89 17
    /**
90 17
     * @TODO 1.0.0 Remove support for 'base' in next major release; it has been replaced by 'baseurl'
91
     *
92 14
     * @return mixed|null
93
     */
94 14
    public function getBaseUrl()
95
    {
96
        $base = $this->returnConfigOption('base');
97
        $baseUrl = $this->returnConfigOption('baseurl');
98
99
        if (is_null($base) || (!empty($baseUrl)))
100
        {
101
            return $baseUrl;
102 4
        }
103
104 4
        return $base;
105 4
    }
106
107 4
    /**
108
     * @return string[]
109 3
     */
110
    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 1
    /**
132
     * @return string[]
133 1
     */
134
    public function getExcludes()
135
    {
136 1
        return $this->returnConfigOption('exclude', array());
137
    }
138 1
139
    /**
140
     * @return string
141 14
     */
142
    public function getTheme()
143 14
    {
144
        return $this->returnConfigOption('theme');
145
    }
146 1
147
    /**
148 1
     * @return array
149
     */
150
    public function getConfiguration()
151 1
    {
152
        return $this->configuration;
153 1
    }
154
155
    /**
156 3
     * @return string[]
157
     */
158 3
    public function getPageViewFolders()
159
    {
160
        return $this->returnConfigOption('pageviews');
161 1
    }
162
163 1
    /**
164
     * @return string
165
     */
166 14
    public function getTargetFolder()
167
    {
168 14
        return $this->returnConfigOption('target');
169
    }
170
171
    /**
172
     * @return string[]
173
     */
174
    public function getCollectionsFolders()
175
    {
176
        return $this->returnConfigOption('collections');
177
    }
178
179
    /**
180
     * @return bool
181
     */
182
    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 30
        return $this->configuration['twig']['autoescape'];
185
    }
186 30
187
    /**
188
     * @return false|string
189 30
     */
190
    public function getRedirectTemplate()
191
    {
192 30
        return $this->configuration['templates']['redirect'];
193 30
    }
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 30
     * @return mixed|null
202 30
     */
203 30
    private function returnConfigOption($name, $default = null)
204
    {
205
        return isset($this->configuration[$name]) ? $this->configuration[$name] : $default;
206
    }
207
208
    ///
209
    // Parsing
210 30
    ///
211 30
212
    /**
213 30
     * 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 30
     *
217
     * @param  string $filePath
218 30
     *
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 2
     */
221
    private static function readFile($filePath)
222 30
    {
223
        $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
        $fileRaw = $fs->safeReadFile($filePath);
225
        $parsed = Yaml::parse($fileRaw);
226
227
        return (null === $parsed) ? array() : $parsed;
228
    }
229
230
    /**
231
     * Parse a configuration file.
232
     *
233
     * @param string|null $configFile
234
     */
235
    public function parse($configFile = null)
236
    {
237
        $this->parentConfig = $configFile;
238
        self::$configImports = array();
239
240
        $this->configuration = $this->parseConfig($configFile);
241
        $this->mergeDefaultConfiguration();
242
        $this->handleDeprecations();
243
244
        self::$configImports = array();
245
    }
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
    private function parseConfig($configFile = null)
259
    {
260
        if (null === $configFile)
261
        {
262
            return array();
263
        }
264
265
        $this->currentFile = $configFile;
266
267
        try
268
        {
269
            $this->isRecursiveImport($configFile);
270
271
            $parsedConfig = self::readFile($configFile);
272
273
            $this->handleImports($parsedConfig);
274
275
            unset($parsedConfig[self::IMPORT_KEYWORD]);
276
            return $parsedConfig;
277
        }
278
        catch (ParseException $e)
279
        {
280
            $this->output->error('{file}: parsing failed... {message}', array(
281
                'message' => $e->getMessage(),
282
                'file' => $configFile,
283
            ));
284
            $this->output->error('Using default configuration...');
285
        }
286
        catch (RecursiveConfigurationException $e)
287
        {
288
            $this->output->error("{file}: you can't recursively import a file that's already been imported: {import}", array(
289
                'file' => $configFile,
290
                'import' => $e->getRecursiveImport(),
291
            ));
292
        }
293
294
        return array();
295
    }
296
297
    /**
298
     * Merge the default configuration with the parsed configuration.
299
     */
300
    private function mergeDefaultConfiguration()
301
    {
302
        $defaultConfig = array(
303
            'baseurl'   => '',
304
            'target'    => '_site',
305
            'twig'      => array(
306
                'autoescape' => false,
307
            ),
308
            'include'   => array(
309
                '.htaccess',
310
            ),
311
            'exclude'   => array(
312
                'node_modules/',
313
                'stakx-theme.yml',
314
                self::DEFAULT_NAME,
315
            ),
316
            'templates' => array(
317
                'redirect' => false,
318
            ),
319
        );
320
321
        $this->configuration = ArrayUtilities::array_merge_defaults($defaultConfig, $this->configuration, 'name');
322
    }
323
324
    /**
325
     * Warn about deprecated keywords in the configuration file.
326
     */
327
    private function handleDeprecations()
328
    {
329
        // @TODO 1.0.0 handle 'base' deprecation in _config.yml
330
        $base = $this->returnConfigOption('base');
331
332
        if (!is_null($base))
333
        {
334
            $this->output->warning("The 'base' configuration option has been replaced by 'baseurl' and will be removed in in version 1.0.0.");
335
        }
336
    }
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
    private function handleImports(array &$configuration)
346
    {
347
        if (!array_key_exists(self::IMPORT_KEYWORD, $configuration))
348
        {
349
            $this->output->debug('{file}: does not import any other files', array(
350
                'file' => $this->parentConfig,
351
            ));
352
353
            return;
354
        }
355
356
        if (!is_array($imports = $configuration[self::IMPORT_KEYWORD]))
357
        {
358
            $this->output->error('{file}: the reserved "import" keyword can only be an array');
359
360
            return;
361
        }
362
363
        $thisFile = $this->fs->getRelativePath($this->parentConfig);
364
        $parentConfigLocation = $this->fs->getFolderPath($thisFile);
365
366
        foreach ($imports as $_import)
0 ignored issues
show
Coding Style introduced by
$_import does not seem to conform to the naming convention (^[a-z][a-zA-Z0-9]*$).

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...
367
        {
368
            if (!is_string($_import))
0 ignored issues
show
Coding Style introduced by
$_import does not seem to conform to the naming convention (^[a-z][a-zA-Z0-9]*$).

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...
369
            {
370
                $this->output->error('{file}: invalid import: {message}', array(
371
                    'file' => $thisFile,
372
                    'message' => $_import,
0 ignored issues
show
Coding Style introduced by
$_import does not seem to conform to the naming convention (^[a-z][a-zA-Z0-9]*$).

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...
373
                ));
374
375
                continue;
376
            }
377
378
            $import = $this->fs->appendPath($parentConfigLocation, $_import);
0 ignored issues
show
Coding Style introduced by
$_import does not seem to conform to the naming convention (^[a-z][a-zA-Z0-9]*$).

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...
379
380
            if (!$this->isValidImport($import))
381
            {
382
                continue;
383
            }
384
385
            $this->output->debug('{file}: imports additional file: {import}', array(
386
                'file' => $thisFile,
387
                'import' => $import,
388
            ));
389
390
            try
391
            {
392
                $importedConfig = $this->parseConfig($import);
393
                $configuration = $this->mergeImports($importedConfig, $configuration);
394
            }
395
            catch (FileAccessDeniedException $e)
396
            {
397
                $this->output->warning('{file}: trying access file outside of project directory: {import}', array(
398
                    'file' => $thisFile,
399
                    'import' => $import,
400
                ));
401
            }
402
            catch (FileNotFoundException $e)
403
            {
404
                $this->output->warning('{file}: could not find file to import: {import}', array(
405
                    'file' => $thisFile,
406
                    'import' => $import,
407
                ));
408
            }
409
        }
410
    }
411
412
    /**
413
     * Check whether a given file path is a valid import.
414
     *
415
     * @param  string $filePath
416
     *
417
     * @return bool
418
     */
419
    private function isValidImport($filePath)
420
    {
421
        $thisFile = $this->fs->getRelativePath($this->parentConfig);
422
        $errorMsg = '';
423
424
        if ($this->fs->isDir($filePath))
425
        {
426
            $errorMsg = 'a directory';
427
        }
428
        elseif ($this->fs->isSymlink($filePath))
429
        {
430
            $errorMsg = 'a symbolically linked file';
431
        }
432
        elseif ($this->fs->absolutePath($this->currentFile) == $this->fs->absolutePath($filePath))
433
        {
434
            $errorMsg = 'yourself';
435
        }
436
        elseif (($ext = $this->fs->getExtension($filePath)) != 'yml' && $ext != 'yaml')
437
        {
438
            $errorMsg = 'a non-YAML configuration';
439
        }
440
441
        if (!($noErrors = empty($errorMsg)))
442
        {
443
            $this->output->error("{file}: you can't import {message}: {import}", array(
444
                'file' => $thisFile,
445
                'message' => $errorMsg,
446
                'import' => $filePath
447
            ));
448
        }
449
450
        return $noErrors;
451
    }
452
453
    /**
454
     * Check whether or not a filename has already been imported in a given process.
455
     *
456
     * @param string $filePath
457
     */
458
    private function isRecursiveImport($filePath)
459
    {
460
        if (in_array($filePath, self::$configImports))
461
        {
462
            throw new RecursiveConfigurationException($filePath, sprintf(
463
                'The %s file has already been imported', $filePath
464
            ));
465
        }
466
467
        self::$configImports[] = $filePath;
468
    }
469
470
    /**
471
     * Merge the given array with existing configuration.
472
     *
473
     * @param  array $importedConfig
474
     * @param  array $existingConfig
475
     *
476
     * @return array
477
     */
478
    private function mergeImports(array $importedConfig, array $existingConfig)
479
    {
480
        $arraySplit = ArrayUtilities::associative_array_split(self::IMPORT_KEYWORD, $existingConfig, false);
481
        $beforeImport = ArrayUtilities::array_merge_defaults($arraySplit[0], $importedConfig, 'name');
482
        $result = ArrayUtilities::array_merge_defaults($beforeImport, $arraySplit[1], 'name');
483
484
        return $result;
485
    }
486
}
487