Passed
Push — master ( 04f525...9e87ce )
by Michael
09:33
created

Yaml::addDependencies()   C

Complexity

Conditions 10
Paths 19

Size

Total Lines 49
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 10.1105

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 49
ccs 26
cts 29
cp 0.8966
rs 5.5471
cc 10
eloc 24
nc 19
nop 4
crap 10.1105

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
        if (empty($unnamed)) {
175 1
            return [];
176
        }
177
178 13
        $documents = [];
179 13
        foreach ($unnamed as $uniqueKey => $document) {
180 13
            $header = YamlParser::parse($document['header']);
181 13
            if(!is_array($header)) {
182 3
                $header = [];
183 3
            }
184 13
            $header = array_change_key_case($header, CASE_LOWER);
185
186 13
            $content = YamlParser::parse($document['content']);
187
188 13
            if (!isset($header['name'])) {
189
                // We automatically name this yaml doc. If it clashes with another, an
190
                // exception will be thrown below.
191 6
                $header['name'] = 'anonymous-'.$uniqueKey;
192 6
            }
193
194
            // Check if a document with that name already exists
195 13
            if (isset($documents[$header['name']])) {
196
                throw new Exception(
197
                    sprintf('More than one YAML document exists named \'%s\'.', $header['name'])
198
                );
199
            }
200
201 13
            $documents[$header['name']] = [
202 13
                'filename' => $document['filename'],
203 13
                'header' => $header,
204 13
                'content' => $content,
205
            ];
206 13
        }
207
208 13
        return $documents;
209
    }
210
211
    /**
212
     * Because multiple documents aren't supported in symfony/yaml, we have to manually
213
     * split the files up into their own documents before running them through the parser.
214
     * Note: This is not a complete implementation of multi-document YAML parsing. There
215
     * are valid yaml cases where this will fail, however they don't match our use-case.
216
     *
217
     * @return array
218
     */
219 14
    protected function splitYamlDocuments()
220
    {
221 14
        $documents = [];
222 14
        $key = 0;
223
224
        // We need to loop through each file and parse the yaml content
225 14
        foreach ($this->files as $file) {
226 14
            $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
227
228 14
            $firstLine = true;
229 14
            $context = 'content';
230 14
            foreach ($lines as $line) {
231 13
                if (empty($line)) {
232
                    continue;
233
                }
234
235 13
                if (!isset($documents[$key])) {
236 13
                    $documents[$key] = [
237 13
                        'filename' => $file,
238 13
                        'header' => '',
239 13
                        'content' => '',
240
                    ];
241 13
                }
242
243 13
                if (($context === 'content' || $firstLine) && ($line === '---' || $line === '...')) {
244
245
                    // '...' is the end of a document with no more documents after it.
246 11
                    if($line === '...') {
247
                        ++$key;
248
                        break;
249
                    }
250
251 11
                    $context = 'header';
252
253
                    // If this isn't the first line (and therefor first doc) we'll increase
254
                    // our document key
255 11
                    if (!$firstLine) {
256 8
                        ++$key;
257 8
                    }
258 13
                } elseif ($context === 'header' && $line === '---') {
259 11
                    $context = 'content';
260 11
                } else {
261 13
                    $documents[$key][$context] .= $line.PHP_EOL;
262
                }
263
264 13
                $firstLine = false;
265 14
            }
266
267
            // Always increase document count at the end of a file
268 14
            ++$key;
269 14
        }
270
271 14
        return $documents;
272
    }
273
274
    /**
275
     * This generates an array of all document depndencies, keyed by document name.
276
     *
277
     * @param array $documents
278
     *
279
     * @return array
280
     */
281 13
    protected function calculateDependencies($documents)
282
    {
283 13
        $dependencies = [];
284 13
        foreach ($documents as $key => $document) {
285 13
            $header = $document['header'];
286
287
            // If the document doesn't have a name, we'll generate one
288 13
            if (!isset($header['name'])) {
289
                $header['name'] = md5($document['filename']).'-'.$key;
290
            }
291
292
            // If our document isn't yet listed in the dependencies we'll add it
293 13
            if (!isset($dependencies[$header['name']])) {
294 13
                $dependencies[$header['name']] = [];
295 13
            }
296
297
            // Add 'after' dependencies
298 13
            $dependencies = $this->addDependencies(
299 13
                $header,
300 13
                self::AFTER_FLAG,
301 13
                $dependencies,
302
                $documents
303 13
            );
304
305
            // Add 'before' dependencies
306 13
            $dependencies = $this->addDependencies(
307 13
                $header,
308 13
                self::BEFORE_FLAG,
309 13
                $dependencies,
310
                $documents
311 13
            );
312 13
        }
313
314 13
        return $dependencies;
315
    }
316
317
    /**
318
     * Incapsulates the logic for adding before/after dependencies.
319
     *
320
     * @param array        $header
321
     * @param string       $flag
322
     * @param array        $dependencies
323
     * @param array        $documents
324
     *
325
     * @return array
326
     */
327 13
    protected function addDependencies($header, $flag, $dependencies, $documents)
328
    {
329
        // If header isn't set then return dependencies
330 13
        if(!isset($header[$flag])) {
331 13
            return $dependencies;
332
        }
333 6
        $name = $header['name'];
334 6
        $currentDocument = $header[$flag];
335
336
        // Normalise our input. YAML accpets string or array values.
337 6
        if (!is_array($currentDocument)) {
338 6
            $currentDocument = [$currentDocument];
339 6
        }
340
341 6
        foreach ($currentDocument as $dependency) {
342
            // Ensure our depdency and current document have dependencies listed
343 6
            if (!isset($dependencies[$name])) {
344
                $dependencies[$name] = [];
345
            }
346
347
            // Because wildcards and hashes exist, our 'dependency' might actually match
348
            // multiple blocks and therefore could be multiple dependencies.
349 6
            $matchingDocuments = $this->getMatchingDocuments($dependency, $documents);
350
351
            // If we have no matching documents, don't add it to dependecies
352 6
            if (empty($matchingDocuments)) {
353 1
                continue;
354
            }
355
356 6
            foreach ($matchingDocuments as $document) {
357 6
                $dependencyName = $document['header']['name'];
358 6
                if (!isset($dependencies[$dependencyName])) {
359 1
                    $dependencies[$dependencyName] = [];
360 1
                }
361
362 6
                if ($flag == self::AFTER_FLAG) {
363
                    // For 'after' we add the given dependency to the current document
364 3
                    $dependencies[$name][] = $dependencyName;
365 6
                } elseif ($flag == self::BEFORE_FLAG) {
366
                    // For 'before' we add the current document as a dependency to $before
367 4
                    $dependencies[$dependencyName][] = $name;
368 4
                } else {
369
                    throw Exception('Invalid flag set for adding dependency.');
370
                }
371 6
            }
372 6
        }
373
374 6
        return $dependencies;
375
    }
376
377
    /**
378
     * This returns an array of documents which match the given pattern. The pattern is
379
     * expected to come from before/after blocks of yaml (eg. framwork/*).
380
     *
381
     * @param string $pattern
382
     * @param array
383
     *
384
     * @return array
385
     */
386 6
    protected function getMatchingDocuments($pattern, $documents)
387
    {
388
        // If the pattern exists as a document name then its just a simple name match
389
        // and we can return that single document.
390 6
        if (isset($documents[$pattern])) {
391 4
            return [$documents[$pattern]];
392
        }
393
394
        // If the pattern starts with a hash, it means we're looking for a single document
395
        // named without the hash.
396 2
        if (strpos($pattern, '#') === 0) {
397 1
            $name = substr($pattern, 1);
398 1
            if (isset($documents[$name])) {
399 1
                return [$documents[$name]];
400
            }
401
402
            return [];
403
        }
404
405
        // If we only have an astericks, we'll add all unnamed docs. By excluding named docs
406
        // we don't run into a circular depndency issue.
407 1
        if($pattern === '*') {
408
            $pattern = 'anonymous-*';
409
        }
410
411
        // Do pattern matching on file names. This requires us to loop through each document
412
        // and check their filename and maybe their document name, depending on the pattern.
413
        // We don't want to do any pattern matching after the first hash as the document name
414
        // is assumed to follow it.
415 1
        $firstHash = strpos('#', $pattern);
416 1
        $documentName = false;
417 1
        if ($firstHash !== false) {
418
            $documentName = substr($pattern, $firstHash + 1);
419
            $pattern = substr($pattern, 0, $firstHash);
420
        }
421
422
        // @todo better sanitisation needed for special chars (chars used by preg_match())
423 1
        $pattern = str_replace(DIRECTORY_SEPARATOR, '\\'.DIRECTORY_SEPARATOR, $pattern);
424 1
        $pattern = str_replace('*', '[^\.][a-zA-Z0-9\-_\/\.]+', $pattern);
425
426 1
        $matchedDocuments = [];
427 1
        foreach ($documents as $document) {
428
            // Ensure filename is relative
429 1
            $filename = $this->makeRelative($document['filename']);
430 1
            if (preg_match('%^'.$pattern.'%', $filename)) {
431 1
                if (!empty($documentName) && $documentName != $document['header']['name']) {
432
                    // If we're looking for a specific document. If not found we can continue
433
                    continue;
434
                }
435
436 1
                $matchedDocuments[] = $document;
437 1
            }
438 1
        }
439
440 1
        return $matchedDocuments;
441
    }
442
443
    /**
444
     * We need this to make the path relative from the base directory. We can't use realpath
445
     * or relative path in Finder because we use a virtual filesystem in testing which
446
     * doesn't support these methods.
447
     *
448
     * @param string $filename
449
     *
450
     * @return string
451
     */
452 1
    protected function makeRelative($filename)
453
    {
454 1
        $dir = substr($filename, 0, strlen($this->baseDirectory));
455 1
        if ($dir == $this->baseDirectory) {
456 1
            return trim(substr($filename, strlen($this->baseDirectory)), DIRECTORY_SEPARATOR);
457
        }
458
459
        return trim($filename, DIRECTORY_SEPARATOR);
460
    }
461
462
    /**
463
     * This method gets all headers and all yaml documents and stores them respectively.
464
     *
465
     * @return array a list of sorted yaml documents
466
     */
467 14
    protected function getSortedYamlDocuments()
468
    {
469 14
        $documents = $this->getNamedYamlDocuments();
470 14
        $documents = $this->filterByOnlyAndExcept($documents);
471
472 14
        if (empty($documents)) {
473 1
            return [];
474
        }
475
476 13
        $dependencies = $this->calculateDependencies($documents);
477
478
        // Now that we've built up our dependencies, we can pass them into
479
        // a topological sort and return the headers.
480 13
        $sorter = new ArraySort();
481 13
        $sorter->set($dependencies);
482 13
        $sorted = $sorter->sort();
483
484 12
        $orderedDocuments = [];
485 12
        foreach ($sorted as $name) {
486 12
            if (!empty($documents[$name])) {
487 12
                $orderedDocuments[$name] = $documents[$name];
488 12
            }
489 12
        }
490
491 12
        return $orderedDocuments;
492
    }
493
494
    /**
495
     * This filteres out any yaml documents which don't pass their only
496
     * or except statement tests.
497
     *
498
     * @param array $documents
499
     *
500
     * @return array
501
     */
502 14
    protected function filterByOnlyAndExcept($documents)
503
    {
504 14
        $filtered = [];
505 14
        foreach($documents as $key => $document) {
506
            // If not all rules match, then we exclude this document
507 13
            if(!$this->testRules($document['header'], self::ONLY_FLAG)) {
508 2
                continue;
509
            }
510
511
            // If all rules pass, then we exclude this document
512 13
            if($this->testRules($document['header'], self::EXCEPT_FLAG)) {
513 3
                continue;
514
            }
515
516 13
            $filtered[$key] = $document;
517 14
        }
518
519 14
        return $filtered;
520
    }
521
522
    /**
523
     * Tests the only except rules for a header.
524
     *
525
     * @param array $header
526
     * @param string $flag
527
     *
528
     * @return boolean
529
     */
530 13
    protected function testRules($header, $flag)
531
    {
532
        // If flag is not set, then it has no tests
533 13
        if(!isset($header[$flag])) {
534
            // We want only to pass, except to fail
535 13
            return $flag === self::ONLY_FLAG;
536
        }
537
538 5
        if(!is_array($header[$flag])) {
539
            throw new Exception(sprintf('\'%s\' statements must be an array', $flag));
540
        }
541
542 5
        foreach($header[$flag] as $rule => $params) {
543 5
            if($this->isRuleIgnored($rule)) {
544
                // If checking only, then return true. Otherwise, return false.
545 1
                return $flag === self::ONLY_FLAG;
546
            }
547
548 4
            if(!$this->testSingleRule($rule, $params)) {
549 2
                return false;
550
            }
551 4
        }
552
553 4
        return true;
554
    }
555
556
    /**
557
     * Tests a rule against the given expected result.
558
     *
559
     * @param string $rule
560
     * @param string|array $params
561
     *
562
     * @return boolean
563
     */
564 4
    protected function testSingleRule($rule, $params)
565
    {
566 4
        if(!$this->hasRule($rule)) {
567
            throw new Exception(sprintf('Rule \'%s\' doesn\'t exist.', $rule));
568
        }
569
570 4
        if(!is_array($params)) {
571 3
            return $this->rules[$rule]($params);
572
        }
573
574
        // If its an array, we'll loop through each parameter
575 1
        foreach($params as $key => $value) {
576 1
            if(!$this->rules[$rule]($key, $value)) {
577 1
                return false;
578
            }
579 1
        }
580
581 1
        return true;
582
    }
583
}
584