Passed
Push — master ( 3bdf73...c101c0 )
by Michael
31s
created

YamlTransformer::addRule()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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