Passed
Push — master ( a0b4b8...4849e7 )
by Damian
03:09
created

YamlTransformer::testRules()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4.0218

Importance

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