Completed
Pull Request — master (#42)
by Vladimir
04:24
created

Configuration::getTargetFolder()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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