Passed
Push — master ( 900239...e4fd04 )
by Michael
03:22
created

Yaml   C

Complexity

Total Complexity 68

Size/Duplication

Total Lines 539
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 3

Test Coverage

Coverage 92.12%

Importance

Changes 4
Bugs 0 Features 1
Metric Value
wmc 68
c 4
b 0
f 1
lcom 1
cbo 3
dl 0
loc 539
ccs 187
cts 203
cp 0.9212
rs 5.6756

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 2
A transform() 0 13 3
A addRule() 0 5 1
A hasRule() 0 5 1
A ignoreRule() 0 5 1
A isRuleIgnored() 0 5 1
B getNamedYamlDocuments() 0 33 5
C splitYamlDocuments() 0 54 13
B calculateDependencies() 0 30 3
C addDependencies() 0 35 8
C getMatchingDocuments() 0 56 10
A makeRelative() 0 9 2
A getSortedYamlDocuments() 0 20 3
A filterByOnlyAndExcept() 0 20 4
B testRules() 0 25 6
B testSingleRule() 0 19 5

How to fix   Complexity   

Complex Class

Complex classes like Yaml 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 Yaml, and based on these observations, apply Extract Interface, too.

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