Passed
Push — master ( 581f43...7abc8c )
by Michael
02:10
created

YamlTransformer::getMatchingDocuments()   C

Complexity

Conditions 10
Paths 19

Size

Total Lines 56
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 11.4067

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 56
ccs 22
cts 29
cp 0.7586
rs 6.7741
cc 10
eloc 25
nc 19
nop 2
crap 11.4067

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