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

Configuration::isValidImport()   C

Complexity

Conditions 7
Paths 10

Size

Total Lines 32
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 7

Importance

Changes 0
Metric Value
dl 0
loc 32
ccs 16
cts 16
cp 1
rs 6.7272
c 0
b 0
f 0
cc 7
eloc 16
nc 10
nop 1
crap 7
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 49
    public function __construct()
64
    {
65 49
        $this->configuration = array();
66 49
        $this->fs = new Filesystem();
67 49
    }
68
69
    /**
70
     * {@inheritdoc}
71
     */
72 49
    public function setLogger(LoggerInterface $logger)
73
    {
74 49
        $this->output = $logger;
75 49
    }
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 49
    private function returnConfigOption($name, $default = null)
204
    {
205 49
        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 36
    private static function readFile($filePath)
222
    {
223 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...
224 36
        $fileRaw = $fs->safeReadFile($filePath);
225 36
        $parsed = Yaml::parse($fileRaw);
226
227 36
        return (null === $parsed) ? array() : $parsed;
228
    }
229
230
    /**
231
     * Parse a configuration file.
232
     *
233
     * @param string|null $configFile
234
     */
235 49
    public function parse($configFile = null)
236
    {
237 49
        $this->parentConfig = $this->fs->getRelativePath($configFile);
238 49
        self::$configImports = array();
239
240 49
        $this->configuration = $this->parseConfig($configFile);
241 49
        $this->mergeDefaultConfiguration();
242 49
        $this->handleDeprecations();
243
244 49
        self::$configImports = array();
245 49
    }
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 49
    private function parseConfig($configFile = null)
259
    {
260 49
        if (null === $configFile)
261
        {
262 49
            return array();
263
        }
264
265 36
        $this->currentFile = $configFile;
266
267
        try
268
        {
269 36
            $this->isRecursiveImport($configFile);
270
271 36
            $parsedConfig = self::readFile($configFile);
272
273 36
            $this->handleImports($parsedConfig);
274
275 36
            unset($parsedConfig[self::IMPORT_KEYWORD]);
276 36
            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 49
    private function mergeDefaultConfiguration()
301
    {
302
        $defaultConfig = array(
303 49
            'baseurl'   => '',
304 49
            'target'    => '_site',
305
            'twig'      => array(
306
                'autoescape' => false,
307
            ),
308
            'include'   => array(
309
                '.htaccess',
310
            ),
311
            'exclude'   => array(
312 49
                'node_modules/',
313 49
                'stakx-theme.yml',
314 49
                self::DEFAULT_NAME,
315
            ),
316
            'templates' => array(
317
                'redirect' => false,
318
            ),
319
        );
320
321 49
        $this->configuration = ArrayUtilities::array_merge_defaults($defaultConfig, $this->configuration, 'name');
322 49
    }
323
324
    /**
325
     * Warn about deprecated keywords in the configuration file.
326
     */
327 49
    private function handleDeprecations()
328
    {
329
        // @TODO 1.0.0 handle 'base' deprecation in _config.yml
330 49
        $base = $this->returnConfigOption('base');
331
332 49
        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 49
    }
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 36
    private function handleImports(array &$configuration)
346
    {
347 36
        if (!array_key_exists(self::IMPORT_KEYWORD, $configuration))
348
        {
349 36
            $this->output->debug('{file}: does not import any other files', array(
350 36
                'file' => $this->parentConfig,
351
            ));
352
353 36
            return;
354
        }
355
356 19
        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 15
        $parentConfigLocation = $this->fs->getFolderPath($this->parentConfig);
364
365 15
        foreach ($imports as $import)
366
        {
367 15
            $this->handleImport($import, $parentConfigLocation, $configuration);
368
        }
369 15
    }
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 15
    private function handleImport($importDef, $parentConfLoc, array &$configuration)
380
    {
381 15
        if (!is_string($importDef))
382
        {
383 3
            $this->output->error('{file}: invalid import: {message}', array(
384 3
                'file' => $this->parentConfig,
385 3
                'message' => $importDef,
386
            ));
387
388 3
            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 36
    private function isRecursiveImport($filePath)
470
    {
471 36
        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 36
        self::$configImports[] = $filePath;
479 36
    }
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