Passed
Push — master ( 8cbf8c...9400f5 )
by Michael
02:33
created

YamlTransformer::makeRelative()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2.032

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 9
ccs 4
cts 5
cp 0.8
rs 9.6666
cc 2
eloc 5
nc 2
nop 1
crap 2.032
1
<?php
2
3
namespace micmania1\config\Transformer;
4
5
use micmania1\config\MergeStrategy\Priority;
6
use micmania1\config\ConfigCollectionInterface;
7
use micmania1\config\ConfigCollection;
8
use Symfony\Component\Yaml\Yaml as YamlParser;
9
use Symfony\Component\Finder\Finder;
10
use MJS\TopSort\Implementations\ArraySort;
11
use Exception;
12
use Closure;
13
14
class YamlTransformer implements TransformerInterface
15
{
16
    /**
17
     * @const string
18
     */
19
    const BEFORE_FLAG = 'before';
20
21
    /**
22
     * @const string
23
     */
24
    const AFTER_FLAG = 'after';
25
26
    /**
27
     * @var string
28
     */
29
    const ONLY_FLAG = 'only';
30
31
    /**
32
     * @var string
33
     */
34
    const EXCEPT_FLAG = 'except';
35
36
    /**
37
     * A list of files. Real, full path.
38
     *
39
     * @var array
40
     */
41
    protected $files = [];
42
43
    /**
44
     * Store the yaml document content as an array.
45
     *
46
     * @var array
47
     */
48
    protected $documents = [];
49
50
    /**
51
     * @var ConfigCollectionInterface
52
     */
53
    protected $collection;
54
55
    /**
56
     * Base directory used to find yaml files.
57
     *
58
     * @var string
59
     */
60
    protected $baseDirectory;
61
62
    /**
63
     * A list of closures to be used in only/except rules.
64
     *
65
     * @var Closure[]
66
     */
67
    protected $rules = [];
68
69
    /**
70
     * A list of ignored before/after statements.
71
     *
72
     * @var array
73
     */
74
    protected $ignoreRules = [];
75
76
    /**
77
     * @param string $baseDir directory to scan for yaml files
78
     * @param Finder $finder
79
     * @param ConfigCollectionInterface $collection
80
     */
81 13
    public function __construct($baseDir, Finder $finder, ConfigCollectionInterface $collection)
82
    {
83 13
        $this->baseDirectory = $baseDir;
84 13
        $this->collection = $collection;
85
86 13
        foreach ($finder as $file) {
87 13
            $this->files[$file->getPathname()] = $file->getPathname();
88 13
        }
89 13
    }
90
91
    /**
92
     * This is responsible for parsing a single yaml file and returning it into a format
93
     * that Config can understand. Config will then be responsible for turning thie
94
     * output into the final merged config.
95
     *
96
     * @return ConfigCollectionInterface
97
     */
98 13
    public function transform()
99
    {
100 13
        $mergeStrategy = new Priority();
101
102 13
        $documents = $this->getSortedYamlDocuments();
103 12
        foreach ($documents as $document) {
104 11
            if (!empty($document['content'])) {
105 11
                $tmpCollection = new ConfigCollection($document['content']);
106 11
                $mergeStrategy->merge($tmpCollection, $this->collection);
107 11
            }
108 12
        }
109
110 12
        return $this->collection;
111
    }
112
113
    /**
114
     * This allows external rules to be added to only/except checks. Config is only
115
     * supposed to be setup once, so adding rules is a one-way system. You cannot
116
     * remove rules after being set. This also prevent built-in rules from being
117
     * removed.
118
     *
119
     * @param string $rule
120
     * @param Closure $func
121
     */
122 5
    public function addRule($rule, Closure $func)
123
    {
124 5
        $rule = strtolower($rule);
125 5
        $this->rules[$rule] = $func;
126 5
    }
127
128
    /**
129
     * Checks to see if a rule is present
130
     *
131
     * @var string
132
     *
133
     * @return boolean
134
     */
135 4
    protected function hasRule($rule)
136
    {
137 4
        $rule = strtolower($rule);
138 4
        return isset($this->rules[$rule]);
139
    }
140
141
    /**
142
     * This allows config to ignore only/except rules that have been set. This enables
143
     * apps to ignore built-in rules without causing errors where a rule is undefined.
144
     * This, is a one-way system and is only meant to be configured once. When you
145
     * ignore a rule, you cannot un-ignore it.
146
     *
147
     * @param string $rule
148
     */
149 1
    public function ignoreRule($rule)
150
    {
151 1
        $rule = strtolower($rule);
152 1
        $this->ignoreRules[$rule] = $rule;
153 1
    }
154
155
    /**
156
     * Checks to see if a rule is ignored
157
     *
158
     * @param string $rule
159
     *
160
     * @return boolean
161
     */
162 5
    protected function isRuleIgnored($rule)
163
    {
164 5
        $rule = strtolower($rule);
165
166 5
        return isset($this->ignoreRules[$rule]);
167
    }
168
169
    /**
170
     * Returns an array of YAML documents keyed by name.
171
     *
172
     * @return array
173
     */
174 13
    protected function getNamedYamlDocuments()
175
    {
176 13
        $unnamed = $this->splitYamlDocuments();
177
178 13
        $documents = [];
179 13
        foreach ($unnamed as $uniqueKey => $document) {
180 12
            $header = YamlParser::parse($document['header']) ?: [];
181 12
            $header = array_change_key_case($header, CASE_LOWER);
182
183 12
            $content = YamlParser::parse($document['content']);
184
185 12
            if (!isset($header['name'])) {
186
                // We automatically name this yaml doc. If it clashes with another, an
187
                // exception will be thrown below.
188 5
                $header['name'] = 'anonymous-'.$uniqueKey;
189 5
            }
190
191
            // Check if a document with that name already exists
192 12
            if (isset($documents[$header['name']])) {
193
                throw new Exception(
194
                    sprintf('More than one YAML document exists named \'%s\'.', $header['name'])
195
                );
196
            }
197
198 12
            $documents[$header['name']] = [
199 12
                'filename' => $document['filename'],
200 12
                'header' => $header,
201 12
                'content' => $content,
202
            ];
203 13
        }
204
205 13
        return $documents;
206
    }
207
208
    /**
209
     * Because multiple documents aren't supported in symfony/yaml, we have to manually
210
     * split the files up into their own documents before running them through the parser.
211
     * Note: This is not a complete implementation of multi-document YAML parsing. There
212
     * are valid yaml cases where this will fail, however they don't match our use-case.
213
     *
214
     * @return array
215
     */
216 13
    protected function splitYamlDocuments()
217
    {
218 13
        $documents = [];
219 13
        $key = 0;
220
221
        // We need to loop through each file and parse the yaml content
222 13
        foreach ($this->files as $file) {
223 13
            $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
224
225 13
            $firstLine = true;
226 13
            $context = 'content';
227 13
            foreach ($lines as $line) {
228 12
                if (empty($line)) {
229
                    continue;
230
                }
231
232 12
                if (!isset($documents[$key])) {
233 12
                    $documents[$key] = [
234 12
                        'filename' => $file,
235 12
                        'header' => '',
236 12
                        'content' => '',
237
                    ];
238 12
                }
239
240 12
                if (($context === 'content' || $firstLine) && ($line === '---' || $line === '...')) {
241
242
                    // '...' is the end of a document with no more documents after it.
243 11
                    if($line === '...') {
244
                        ++$key;
245
                        break;
246
                    }
247
248 11
                    $context = 'header';
249
250
                    // If this isn't the first line (and therefor first doc) we'll increase
251
                    // our document key
252 11
                    if (!$firstLine) {
253 8
                        ++$key;
254 8
                    }
255 12
                } elseif ($context === 'header' && $line === '---') {
256 11
                    $context = 'content';
257 11
                } else {
258 12
                    $documents[$key][$context] .= $line.PHP_EOL;
259
                }
260
261 12
                $firstLine = false;
262 13
            }
263
264
            // Always increase document count at the end of a file
265 13
            ++$key;
266 13
        }
267
268 13
        return $documents;
269
    }
270
271
    /**
272
     * This generates an array of all document depndencies, keyed by document name.
273
     *
274
     * @param array $documents
275
     *
276
     * @return array
277
     */
278 13
    protected function calculateDependencies($documents)
279
    {
280 13
        $dependencies = [];
281 13
        foreach ($documents as $key => $document) {
282 12
            $header = $document['header'];
283
284
            // If our document isn't yet listed in the dependencies we'll add it
285 12
            if (!isset($dependencies[$header['name']])) {
286 12
                $dependencies[$header['name']] = [];
287 12
            }
288
289
            // Add 'after' dependencies
290 12
            $dependencies = $this->addDependencies(
291 12
                $header,
292 12
                self::AFTER_FLAG,
293 12
                $dependencies,
294
                $documents
295 12
            );
296
297
            // Add 'before' dependencies
298 12
            $dependencies = $this->addDependencies(
299 12
                $header,
300 12
                self::BEFORE_FLAG,
301 12
                $dependencies,
302
                $documents
303 12
            );
304 13
        }
305
306 13
        return $dependencies;
307
    }
308
309
    /**
310
     * Incapsulates the logic for adding before/after dependencies.
311
     *
312
     * @param array        $header
313
     * @param string       $flag
314
     * @param array        $dependencies
315
     * @param array        $documents
316
     *
317
     * @return array
318
     */
319 12
    protected function addDependencies($header, $flag, $dependencies, $documents)
320
    {
321
        // If header isn't set then return dependencies
322 12
        if(!isset($header[$flag]) || !in_array($flag, [self::BEFORE_FLAG, self::AFTER_FLAG])) {
323 12
            return $dependencies;
324
        }
325
326
        // Normalise our input. YAML accpets string or array values.
327 6
        if (!is_array($header[$flag])) {
328 6
            $header[$flag] = [$header[$flag]];
329 6
        }
330
331 6
        foreach ($header[$flag] as $dependency) {
332
            // Because wildcards and hashes exist, our 'dependency' might actually match
333
            // multiple blocks and therefore could be multiple dependencies.
334 6
            $matchingDocuments = $this->getMatchingDocuments($dependency, $documents);
335
336 6
            foreach ($matchingDocuments as $document) {
337 6
                $dependencyName = $document['header']['name'];
338 6
                if (!isset($dependencies[$dependencyName])) {
339 1
                    $dependencies[$dependencyName] = [];
340 1
                }
341
342 6
                if ($flag == self::AFTER_FLAG) {
343
                    // For 'after' we add the given dependency to the current document
344 3
                    $dependencies[$header['name']][] = $dependencyName;
345 3
                } else {
346
                    // For 'before' we add the current document as a dependency to $before
347 4
                    $dependencies[$dependencyName][] = $header['name'];
348
                }
349 6
            }
350 6
        }
351
352 6
        return $dependencies;
353
    }
354
355
    /**
356
     * This returns an array of documents which match the given pattern. The pattern is
357
     * expected to come from before/after blocks of yaml (eg. framwork/*).
358
     *
359
     * @param string $pattern
360
     * @param array
361
     *
362
     * @return array
363
     */
364 6
    protected function getMatchingDocuments($pattern, $documents)
365
    {
366
        // If the pattern exists as a document name then its just a simple name match
367
        // and we can return that single document.
368 6
        if (isset($documents[$pattern])) {
369 4
            return [$documents[$pattern]];
370
        }
371
372
        // If the pattern starts with a hash, it means we're looking for a single document
373
        // named without the hash.
374 2
        if (strpos($pattern, '#') === 0) {
375 1
            $name = substr($pattern, 1);
376 1
            if (isset($documents[$name])) {
377 1
                return [$documents[$name]];
378
            }
379
380
            return [];
381
        }
382
383
        // If we only have an astericks, we'll add all unnamed docs. By excluding named docs
384
        // we don't run into a circular depndency issue.
385 1
        if($pattern === '*') {
386
            $pattern = 'anonymous-*';
387
        }
388
389
        // Do pattern matching on file names. This requires us to loop through each document
390
        // and check their filename and maybe their document name, depending on the pattern.
391
        // We don't want to do any pattern matching after the first hash as the document name
392
        // is assumed to follow it.
393 1
        $firstHash = strpos('#', $pattern);
394 1
        $documentName = false;
395 1
        if ($firstHash !== false) {
396
            $documentName = substr($pattern, $firstHash + 1);
397
            $pattern = substr($pattern, 0, $firstHash);
398
        }
399
400
        // @todo better sanitisation needed for special chars (chars used by preg_match())
401 1
        $pattern = str_replace(DIRECTORY_SEPARATOR, '\\'.DIRECTORY_SEPARATOR, $pattern);
402 1
        $pattern = str_replace('*', '[^\.][a-zA-Z0-9\-_\/\.]+', $pattern);
403
404 1
        $matchedDocuments = [];
405 1
        foreach ($documents as $document) {
406
            // Ensure filename is relative
407 1
            $filename = $this->makeRelative($document['filename']);
408 1
            if (preg_match('%^'.$pattern.'%', $filename)) {
409 1
                if (!empty($documentName) && $documentName != $document['header']['name']) {
410
                    // If we're looking for a specific document. If not found we can continue
411
                    continue;
412
                }
413
414 1
                $matchedDocuments[] = $document;
415 1
            }
416 1
        }
417
418 1
        return $matchedDocuments;
419
    }
420
421
    /**
422
     * We need this to make the path relative from the base directory. We can't use realpath
423
     * or relative path in Finder because we use a virtual filesystem in testing which
424
     * doesn't support these methods.
425
     *
426
     * @param string $filename
427
     *
428
     * @return string
429
     */
430 1
    protected function makeRelative($filename)
431
    {
432 1
        $dir = substr($filename, 0, strlen($this->baseDirectory));
433 1
        if ($dir == $this->baseDirectory) {
434 1
            return trim(substr($filename, strlen($this->baseDirectory)), DIRECTORY_SEPARATOR);
435
        }
436
437
        return trim($filename, DIRECTORY_SEPARATOR);
438
    }
439
440
    /**
441
     * This method gets all headers and all yaml documents and stores them respectively.
442
     *
443
     * @return array a list of sorted yaml documents
444
     */
445 13
    protected function getSortedYamlDocuments()
446
    {
447 13
        $documents = $this->filterByOnlyAndExcept();
448 13
        $dependencies = $this->calculateDependencies($documents);
449
450
        // Now that we've built up our dependencies, we can pass them into
451
        // a topological sort and return the headers.
452 13
        $sorter = new ArraySort();
453 13
        $sorter->set($dependencies);
454 13
        $sorted = $sorter->sort();
455
456 12
        $orderedDocuments = [];
457 12
        foreach ($sorted as $name) {
458 11
            if (!empty($documents[$name])) {
459 11
                $orderedDocuments[$name] = $documents[$name];
460 11
            }
461 12
        }
462
463 12
        return $orderedDocuments;
464
    }
465
466
    /**
467
     * This filteres out any yaml documents which don't pass their only
468
     * or except statement tests.
469
     *
470
     * @return array
471
     */
472 13
    protected function filterByOnlyAndExcept()
473
    {
474 13
        $documents = $this->getNamedYamlDocuments();
475 13
        $filtered = [];
476 13
        foreach($documents as $key => $document) {
477
            // If not all rules match, then we exclude this document
478 12
            if(!$this->testRules($document['header'], self::ONLY_FLAG)) {
479 2
                continue;
480
            }
481
482
            // If all rules pass, then we exclude this document
483 12
            if($this->testRules($document['header'], self::EXCEPT_FLAG)) {
484 3
                continue;
485
            }
486
487 12
            $filtered[$key] = $document;
488 13
        }
489
490 13
        return $filtered;
491
    }
492
493
    /**
494
     * Tests the only except rules for a header.
495
     *
496
     * @param array $header
497
     * @param string $flag
498
     *
499
     * @return boolean
500
     */
501 12
    protected function testRules($header, $flag)
502
    {
503
        // If flag is not set, then it has no tests
504 12
        if(!isset($header[$flag])) {
505
            // We want only to pass, except to fail
506 12
            return $flag === self::ONLY_FLAG;
507
        }
508
509 5
        if(!is_array($header[$flag])) {
510
            throw new Exception(sprintf('\'%s\' statements must be an array', $flag));
511
        }
512
513 5
        foreach($header[$flag] as $rule => $params) {
514 5
            if($this->isRuleIgnored($rule)) {
515
                // If checking only, then return true. Otherwise, return false.
516 1
                return $flag === self::ONLY_FLAG;
517
            }
518
519 4
            if(!$this->testSingleRule($rule, $params)) {
520 2
                return false;
521
            }
522 4
        }
523
524 4
        return true;
525
    }
526
527
    /**
528
     * Tests a rule against the given expected result.
529
     *
530
     * @param string $rule
531
     * @param string|array $params
532
     *
533
     * @return boolean
534
     */
535 4
    protected function testSingleRule($rule, $params)
536
    {
537 4
        if(!$this->hasRule($rule)) {
538
            throw new Exception(sprintf('Rule \'%s\' doesn\'t exist.', $rule));
539
        }
540
541 4
        if(!is_array($params)) {
542 3
            return $this->rules[$rule]($params);
543
        }
544
545
        // If its an array, we'll loop through each parameter
546 1
        foreach($params as $key => $value) {
547 1
            if(!$this->rules[$rule]($key, $value)) {
548 1
                return false;
549
            }
550 1
        }
551
552 1
        return true;
553
    }
554
}
555