YamlTransformer::transform()   B
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 4

Importance

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