Completed
Push — master ( 80d5dd...e32bbf )
by Vladimir
03:02
created

Configuration::readFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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