YamlTransformer   F
last analyzed

Complexity

Total Complexity 76

Size/Duplication

Total Lines 623
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 3

Test Coverage

Coverage 87.14%

Importance

Changes 0
Metric Value
wmc 76
lcom 1
cbo 3
dl 0
loc 623
ccs 210
cts 241
cp 0.8714
rs 2.297
c 0
b 0
f 0

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 2
A create() 0 4 1
A transform() 0 29 4
A addRule() 0 6 1
A hasRule() 0 5 1
A ignoreRule() 0 5 1
A isRuleIgnored() 0 6 1
B getNamedYamlDocuments() 0 40 5
C splitYamlDocuments() 0 53 13
A calculateDependencies() 0 30 3
B addDependencies() 0 35 8
C getMatchingDocuments() 0 75 12
A makeRelative() 0 9 2
A getSortedYamlDocuments() 0 20 3
A filterByOnlyAndExcept() 0 20 4
A testRules() 0 21 4
A testSingleRule() 0 23 4
B evaluateConditions() 0 23 7

How to fix   Complexity   

Complex Class

Complex classes like YamlTransformer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use YamlTransformer, and based on these observations, apply Extract Interface, too.

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 14
    public function __construct($baseDir, Finder $finder)
75
    {
76 14
        $this->baseDirectory = $baseDir;
77
78 14
        foreach ($finder as $file) {
79 14
            $this->files[$file->getPathname()] = $file->getPathname();
80 14
        }
81 14
    }
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 14
    public function transform(MutableConfigCollectionInterface $collection)
102
    {
103 14
        $documents = $this->getSortedYamlDocuments();
104
105 13
        foreach ($documents as $document) {
106 12
            if (!empty($document['content'])) {
107
                // We prepare the meta data
108 12
                $metadata = $document['header'];
109 12
                $metadata['transformer'] = static::class;
110 12
                $metadata['filename'] = $document['filename'];
111
112
                // We create a temporary collection for each document before merging it
113
                // into the existing collection
114 12
                $items = [];
115
116
                // And create each item
117 12
                foreach ($document['content'] as $key => $value) {
118 12
                    $items[$key] = [
119 12
                        'value' => $value,
120
                        'metadata' => $metadata
121 12
                    ];
122 12
                }
123
124 12
                Priority::merge($items, $collection);
125 12
            }
126 13
        }
127
128 13
        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 14
    protected function getNamedYamlDocuments()
196
    {
197 14
        $unnamed = $this->splitYamlDocuments();
198
199 14
        $documents = [];
200 14
        foreach ($unnamed as $uniqueKey => $document) {
201 13
            $header = YamlParser::parse($document['header']) ?: [];
202 13
            $header = array_change_key_case($header, CASE_LOWER);
203
204 13
            $content = YamlParser::parse($document['content']);
205
206 13
            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 13
            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 13
            $documents[$header['name']] = [
227 13
                'filename' => $document['filename'],
228 13
                'header' => $header,
229 13
                'content' => $content,
230
            ];
231 14
        }
232
233 14
        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 14
    protected function splitYamlDocuments()
245
    {
246 14
        $documents = [];
247 14
        $key = 0;
248
249
        // We need to loop through each file and parse the yaml content
250 14
        foreach ($this->files as $file) {
251 14
            $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
252
253 14
            $firstLine = true;
254 14
            $context = 'content';
255 14
            foreach ($lines as $line) {
256 13
                if (empty($line)) {
257
                    continue;
258
                }
259
260 13
                if (!isset($documents[$key])) {
261 13
                    $documents[$key] = [
262 13
                        'filename' => $file,
263 13
                        'header' => '',
264 13
                        'content' => '',
265
                    ];
266 13
                }
267
268 13
                if (($context === 'content' || $firstLine) && ($line === '---' || $line === '...')) {
269
                    // '...' is the end of a document with no more documents after it.
270 12
                    if ($line === '...') {
271
                        ++$key;
272
                        break;
273
                    }
274
275 12
                    $context = 'header';
276
277
                    // If this isn't the first line (and therefor first doc) we'll increase
278
                    // our document key
279 12
                    if (!$firstLine) {
280 8
                        ++$key;
281 8
                    }
282 13
                } elseif ($context === 'header' && $line === '---') {
283 12
                    $context = 'content';
284 12
                } else {
285 13
                    $documents[$key][$context] .= $line.PHP_EOL;
286
                }
287
288 13
                $firstLine = false;
289 14
            }
290
291
            // Always increase document count at the end of a file
292 14
            ++$key;
293 14
        }
294
295 14
        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 14
    protected function calculateDependencies($documents)
306
    {
307 14
        $dependencies = [];
308 14
        foreach ($documents as $key => $document) {
309 13
            $header = $document['header'];
310
311
            // If our document isn't yet listed in the dependencies we'll add it
312 13
            if (!isset($dependencies[$header['name']])) {
313 13
                $dependencies[$header['name']] = [];
314 13
            }
315
316
            // Add 'after' dependencies
317 13
            $dependencies = $this->addDependencies(
318 13
                $header,
319 13
                self::AFTER_FLAG,
320 13
                $dependencies,
321
                $documents
322 13
            );
323
324
            // Add 'before' dependencies
325 13
            $dependencies = $this->addDependencies(
326 13
                $header,
327 13
                self::BEFORE_FLAG,
328 13
                $dependencies,
329
                $documents
330 13
            );
331 14
        }
332
333 14
        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 13
    protected function addDependencies($header, $flag, $dependencies, $documents)
347
    {
348
        // If header isn't set then return dependencies
349 13
        if (!isset($header[$flag]) || !in_array($flag, [self::BEFORE_FLAG, self::AFTER_FLAG])) {
350 13
            return $dependencies;
351
        }
352
353
        // Normalise our input. YAML accpets string or array values.
354 8
        if (!is_array($header[$flag])) {
355 8
            $header[$flag] = [$header[$flag]];
356 8
        }
357
358 8
        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 8
            $matchingDocuments = $this->getMatchingDocuments($dependency, $documents, $flag);
362
363 8
            foreach ($matchingDocuments as $document) {
364 8
                $dependencyName = $document['header']['name'];
365 8
                if (!isset($dependencies[$dependencyName])) {
366 1
                    $dependencies[$dependencyName] = [];
367 1
                }
368
369 8
                if ($flag == self::AFTER_FLAG) {
370
                    // For 'after' we add the given dependency to the current document
371 6
                    $dependencies[$header['name']][] = $dependencyName;
372 6
                } else {
373
                    // For 'before' we add the current document as a dependency to $before
374 5
                    $dependencies[$dependencyName][] = $header['name'];
375
                }
376 8
            }
377 8
        }
378
379 8
        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 8
    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 8
        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 3
        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 2
        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 2
        $firstHash = strpos($pattern, '#');
432 2
        $documentName = false;
433 2
        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 2
        $patternRegExp = '%(^|[/\\\\])'.implode(
440 2
            '[^\.][a-zA-Z0-9\-_\/\.]+',
441 2
            array_map(
442
                function ($part) {
443 2
                    return preg_quote($part, '%');
444 2
                },
445 2
                explode('*', trim($pattern, '/\\'))
446 2
            )
447 2
        ).'([./\\\\]|$)%';
448
449 2
        $matchedDocuments = [];
450 2
        foreach ($documents as $document) {
451
            // Ensure filename is relative
452 2
            $filename = $this->makeRelative($document['filename']);
453
454 2
            if (preg_match($patternRegExp, $filename)) {
455 2
                if (!empty($documentName) && $documentName !== $document['header']['name']) {
456
                    // If we're looking for a specific document. If not found we can continue
457
                    continue;
458
                }
459
460 2
                $matchedDocuments[] = $document;
461 2
            }
462 2
        }
463
464 2
        return $matchedDocuments;
465
    }
466
467
    /**
468
     * We need this to make the path relative from the base directory. We can't use realpath
469
     * or relative path in Finder because we use a virtual filesystem in testing which
470
     * doesn't support these methods.
471
     *
472
     * @param string $filename
473
     *
474
     * @return string
475
     */
476 2
    protected function makeRelative($filename)
477
    {
478 2
        $dir = substr($filename, 0, strlen($this->baseDirectory));
479 2
        if ($dir == $this->baseDirectory) {
480 2
            return trim(substr($filename, strlen($this->baseDirectory)), DIRECTORY_SEPARATOR);
481
        }
482
483
        return trim($filename, DIRECTORY_SEPARATOR);
484
    }
485
486
    /**
487
     * This method gets all headers and all yaml documents and stores them respectively.
488
     *
489
     * @return array a list of sorted yaml documents
490
     */
491 14
    protected function getSortedYamlDocuments()
492
    {
493 14
        $documents = $this->filterByOnlyAndExcept();
494 14
        $dependencies = $this->calculateDependencies($documents);
495
496
        // Now that we've built up our dependencies, we can pass them into
497
        // a topological sort and return the headers.
498 14
        $sorter = new ArraySort();
499 14
        $sorter->set($dependencies);
500 14
        $sorted = $sorter->sort();
501
502 13
        $orderedDocuments = [];
503 13
        foreach ($sorted as $name) {
504 12
            if (!empty($documents[$name])) {
505 12
                $orderedDocuments[$name] = $documents[$name];
506 12
            }
507 13
        }
508
509 13
        return $orderedDocuments;
510
    }
511
512
    /**
513
     * This filteres out any yaml documents which don't pass their only
514
     * or except statement tests.
515
     *
516
     * @return array
517
     */
518 14
    protected function filterByOnlyAndExcept()
519
    {
520 14
        $documents = $this->getNamedYamlDocuments();
521 14
        $filtered = [];
522 14
        foreach ($documents as $key => $document) {
523
            // If not all rules match, then we exclude this document
524 13
            if (!$this->testRules($document['header'], self::ONLY_FLAG)) {
525 2
                continue;
526
            }
527
528
            // If all rules pass, then we exclude this document
529 13
            if ($this->testRules($document['header'], self::EXCEPT_FLAG)) {
530 4
                continue;
531
            }
532
533 13
            $filtered[$key] = $document;
534 14
        }
535
536 14
        return $filtered;
537
    }
538
539
    /**
540
     * Tests the only except rules for a header.
541
     *
542
     * @param  array  $header
543
     * @param  string $flag
544
     * @return bool
545
     * @throws Exception
546
     */
547 13
    protected function testRules($header, $flag)
548
    {
549
        // If flag is not set, then it has no tests
550 13
        if (!isset($header[$flag])) {
551
            // We want only to pass, except to fail
552 13
            return $flag === self::ONLY_FLAG;
553
        }
554
555 5
        if (!is_array($header[$flag])) {
556
            throw new Exception(sprintf('\'%s\' statements must be an array', $flag));
557
        }
558
559
        return $this->evaluateConditions($header[$flag], $flag, function ($rule, $params) use ($flag) {
560
            // Skip ignored rules
561 5
            if ($this->isRuleIgnored($rule)) {
562 1
                return null;
563
            }
564
565 4
            return $this->testSingleRule($rule, $params, $flag);
566 5
        });
567
    }
568
569
    /**
570
     * Tests a rule against the given expected result.
571
     *
572
     * @param string $rule
573
     * @param string|array $params
574
     * @param string $flag
575
     * @return bool
576
     * @throws Exception
577
     */
578 4
    protected function testSingleRule($rule, $params, $flag = self::ONLY_FLAG)
579
    {
580 4
        $rule = strtolower($rule);
581 4
        if (!$this->hasRule($rule)) {
582
            throw new Exception(sprintf('Rule \'%s\' doesn\'t exist.', $rule));
583
        }
584 4
        $ruleCallback = $this->rules[$rule];
585
586
        // Expand single rule into array
587 4
        if (!is_array($params)) {
588 3
            $params = [$params];
589 3
        }
590
591
        // Evaluate all rules
592 4
        return $this->evaluateConditions($params, $flag, function ($key, $value) use ($ruleCallback) {
593
            // Don't treat keys as argument if numeric
594 4
            if (is_numeric($key)) {
595 3
                return $ruleCallback($value);
596
            }
597
598 1
            return $ruleCallback($key, $value);
599 4
        });
600
    }
601
602
    /**
603
     * Evaluate condition against a set of data using the appropriate conjuction for the flag
604
     *
605
     * @param array $source Items to apply condition to
606
     * @param string $flag Flag type
607
     * @param callable $condition Callback to evaluate each item in the $source. Both key and value
608
     * of each item in $source will be passed as arguments. This callback should return true, false,
609
     * or null to skip
610
     * @return bool Evaluation of the applied condition
611
     */
612 5
    protected function evaluateConditions($source, $flag, $condition)
613
    {
614 5
        foreach ($source as $key => $value) {
615 5
            $result = $condition($key, $value);
616
617
            // Skip if null
618 5
            if ($result === null) {
619 1
                continue;
620
            }
621
622
            // Only fails if any are false
623 4
            if ($flag === self::ONLY_FLAG && !$result) {
624 2
                return false;
625
            }
626
            // Except succeeds if any true
627 4
            if ($flag === self::EXCEPT_FLAG && $result) {
628 4
                return true;
629
            }
630 5
        }
631
632
        // Default based on flag
633 5
        return $flag === self::ONLY_FLAG;
634
    }
635
}
636