Passed
Push — master ( ff5f97...686573 )
by Michael
02:29
created

Yaml::hasRule()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
dl 0
loc 5
ccs 3
cts 3
cp 1
rs 9.4285
c 1
b 0
f 1
cc 1
eloc 3
nc 1
nop 1
crap 1
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
     * @param string $dir directory to scan for yaml files
0 ignored issues
show
Bug introduced by
There is no parameter named $dir. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
69
     */
70 14
    public function __construct($baseDir, Finder $finder, $sort = 0)
71
    {
72 14
        $this->baseDirectory = $baseDir;
73 14
        $this->sort = $sort;
74
75 14
        foreach ($finder as $file) {
76 14
            $this->files[$file->getPathname()] = $file->getPathname();
77 14
        }
78 14
    }
79
80
    /**
81
     * This is responsible for parsing a single yaml file and returning it into a format
82
     * that Config can understand. Config will then be responsible for turning thie
83
     * output into the final merged config.
84
     *
85
     * @return array
86
     */
87 14
    public function transform()
88
    {
89 14
        $this->merged = [];
0 ignored issues
show
Bug introduced by
The property merged does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
90
91 14
        if (empty($this->files)) {
92
            return merged;
93
        }
94
95 14
        $documents = $this->getSortedYamlDocuments();
96 13
        $config = [];
97 13
        $mergeStrategy = new Priority();
98 13
        foreach ($documents as $document) {
99 12
            if (!empty($document['content'])) {
100 12
                $config = $mergeStrategy->merge($document['content'], $config);
101 12
            }
102 13
        }
103
104 13
        return [$this->sort => $config];
105
    }
106
107
    /**
108
     * This allows external rules to be added to only/except checks. Config is only
109
     * supposed to be setup once, so adding rules is a one-way system. You cannot
110
     * remove rules after being set. This also prevent built-in rules from being
111
     * removed.
112
     *
113
     * @param string $rule
114
     * @param Closure $func
115
     */
116 5
    public function addRule($rule, Closure $func)
117
    {
118 5
        $rule = strtolower($rule);
119 5
        $this->rules[$rule] = $func;
120 5
    }
121
122
    /**
123
     * Checks to see if a rule is present
124
     *
125
     * @var string
126
     *
127
     * @return boolean
128
     */
129 4
    protected function hasRule($rule)
130
    {
131 4
        $rule = strtolower($rule);
132 4
        return isset($this->rules[$rule]);
133
    }
134
135
    /**
136
     * This allows config to ignore only/except rules that have been set. This enables
137
     * apps to ignore built-in rules without causing errors where a rule is undefined.
138
     * This, is a one-way system and is only meant to be configured once. When you
139
     * ignore a rule, you cannot un-ignore it.
140
     *
141
     * @param string $rule
142
     */
143 1
    public function ignoreRule($rule)
144
    {
145 1
        $rule = strtolower($rule);
146 1
        $this->ignoreRules[$rule] = $rule;
0 ignored issues
show
Bug introduced by
The property ignoreRules does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
147 1
    }
148
149
    /**
150
     * Checks to see if a rule is ignored
151
     *
152
     * @param string $rule
153
     *
154
     * @return boolean
155
     */
156 5
    protected function isRuleIgnored($rule)
157
    {
158 5
        $rule = strtolower($rule);
159 5
        return isset($this->ignoreRules[$rule]);
160
    }
161
162
    /**
163
     * Returns an array of YAML documents keyed by name.
164
     *
165
     * @return array;
0 ignored issues
show
Documentation introduced by
The doc-type array; could not be parsed: Expected "|" or "end of type", but got ";" at position 5. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
166
     */
167 14
    protected function getNamedYamlDocuments()
168
    {
169 14
        $unnamed = $this->splitYamlDocuments();
170
171 14
        if (empty($unnamed)) {
172 1
            return [];
173
        }
174
175 13
        $documents = [];
176 13
        foreach ($unnamed as $uniqueKey => $document) {
177 13
            $header = YamlParser::parse($document['header']);
178 13
            if(!is_array($header)) {
179 3
                $header = [];
180 3
            }
181 13
            $header = array_change_key_case($header, CASE_LOWER);
182
183 13
            $content = YamlParser::parse($document['content']);
184
185 13
            if (!isset($header['name'])) {
186
                // We automatically name this yaml doc. If it clashes with another, an
187
                // exception will be thrown below.
188 6
                $header['name'] = 'anonymous-'.$uniqueKey;
189 6
            }
190
191
            // Check if a document with that name already exists
192 13
            if (isset($documents[$header['name']])) {
193
                throw new Exception(
194
                    sprintf('More than one YAML document exists named \'%s\'.', $header['name'])
195
                );
196
            }
197
198 13
            $documents[$header['name']] = [
199 13
                'filename' => $document['filename'],
200 13
                'header' => $header,
201 13
                'content' => $content,
202
            ];
203 13
        }
204
205 13
        return $documents;
206
    }
207
208
    /**
209
     * Because multiple documents aren't supported in symfony/yaml, we have to manually
210
     * split the files up into their own documents before running them through the parser.
211
     * Note: This is not a complete implementation of multi-document YAML parsing. There
212
     * are valid yaml cases where this will fail, however they don't match our use-case.
213
     *
214
     * @return array
215
     */
216 14
    protected function splitYamlDocuments()
217
    {
218 14
        $documents = [];
219 14
        $key = 0;
220
221
        // We need to loop through each file and parse the yaml content
222 14
        foreach ($this->files as $file) {
223 14
            $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
224
225 14
            $firstLine = true;
226 14
            $context = 'content';
227 14
            foreach ($lines as $line) {
228 13
                if (empty($line)) {
229
                    continue;
230
                }
231
232 13
                if (!isset($documents[$key])) {
233 13
                    $documents[$key] = [
234 13
                        'filename' => $file,
235 13
                        'header' => '',
236 13
                        'content' => '',
237
                    ];
238 13
                }
239
240 13
                if (($context === 'content' || $firstLine) && ($line === '---' || $line === '...')) {
241
242
                    // '...' is the end of a document with no more documents after it.
243 11
                    if($line === '...') {
244
                        ++$key;
245
                        break;
246
                    }
247
248 11
                    $context = 'header';
249
250
                    // If this isn't the first line (and therefor first doc) we'll increase
251
                    // our document key
252 11
                    if (!$firstLine) {
253 8
                        ++$key;
254 8
                    }
255 13
                } elseif ($context === 'header' && $line === '---') {
256 11
                    $context = 'content';
257 11
                } else {
258 13
                    $documents[$key][$context] .= $line.PHP_EOL;
259
                }
260
261 13
                $firstLine = false;
262 14
            }
263
264
            // Always increase document count at the end of a file
265 14
            ++$key;
266 14
        }
267
268 14
        return $documents;
269
    }
270
271
    /**
272
     * This generates an array of all document depndencies, keyed by document name.
273
     *
274
     * @param array $documents
275
     *
276
     * @return array
277
     */
278 13
    protected function calculateDependencies($documents)
279
    {
280 13
        $dependencies = [];
281 13
        foreach ($documents as $key => $document) {
282 13
            $header = $document['header'];
283 13
            $content = $document['content'];
0 ignored issues
show
Unused Code introduced by
$content is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
284
285
            // If the document doesn't have a name, we'll generate one
286 13
            if (!isset($header['name'])) {
287
                $header['name'] = md5($document['filename']).'-'.$key;
288
            }
289
290
            // If our document isn't yet listed in the dependencies we'll add it
291 13
            if (!isset($dependencies[$header['name']])) {
292 13
                $dependencies[$header['name']] = [];
293 13
            }
294
295
            // Add 'after' dependencies
296 13 View Code Duplication
            if (isset($header[self::AFTER_FLAG])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
297 4
                $dependencies = $this->addDependencies(
298 4
                    $header[self::AFTER_FLAG],
299 4
                    $header['name'],
300 4
                    self::AFTER_FLAG,
301 4
                    $dependencies,
302
                    $documents
303 4
                );
304 4
            }
305
306
            // Add 'before' dependencies
307 13 View Code Duplication
            if (isset($header[self::BEFORE_FLAG])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
308 4
                $dependencies = $this->addDependencies(
309 4
                    $header[self::BEFORE_FLAG],
310 4
                    $header['name'],
311 4
                    self::BEFORE_FLAG,
312 4
                    $dependencies,
313
                    $documents
314 4
                );
315 4
            }
316 13
        }
317
318 13
        return $dependencies;
319
    }
320
321
    /**
322
     * Incapsulates the logic for adding before/after dependencies.
323
     *
324
     * @param array|string $currentDocument
325
     * @param string       $name
326
     * @param string       $flag
327
     * @param array        $dependencies
328
     * @param array        $documents
329
     *
330
     * @return array
331
     */
332 6
    protected function addDependencies($currentDocument, $name, $flag, $dependencies, $documents)
333
    {
334
        // Normalise our input. YAML accpets string or array values.
335 6
        if (!is_array($currentDocument)) {
336 6
            $currentDocument = [$currentDocument];
337 6
        }
338
339 6
        foreach ($currentDocument as $dependency) {
340
            // Ensure our depdency and current document have dependencies listed
341 6
            if (!isset($dependencies[$name])) {
342
                $dependencies[$name] = [];
343
            }
344
345
            // Because wildcards and hashes exist, our 'dependency' might actually match
346
            // multiple blocks and therefore could be multiple dependencies.
347 6
            $matchingDocuments = $this->getMatchingDocuments($dependency, $documents);
348
349
            // If we have no matching documents, don't add it to dependecies
350 6
            if (empty($matchingDocuments)) {
351 1
                continue;
352
            }
353
354 6
            foreach ($matchingDocuments as $document) {
355 6
                $dependencyName = $document['header']['name'];
356 6
                if (!isset($dependencies[$dependencyName])) {
357 1
                    $dependencies[$dependencyName] = [];
358 1
                }
359
360 6
                if ($flag == self::AFTER_FLAG) {
361
                    // For 'after' we add the given dependency to the current document
362 3
                    $dependencies[$name][] = $dependencyName;
363 6
                } elseif ($flag == self::BEFORE_FLAG) {
364
                    // For 'before' we add the current document as a dependency to $before
365 4
                    $dependencies[$dependencyName][] = $name;
366 4
                } else {
367
                    throw Exception('Invalid flag set for adding dependency.');
368
                }
369 6
            }
370 6
        }
371
372 6
        return $dependencies;
373
    }
374
375
    /**
376
     * This returns an array of documents which match the given pattern. The pattern is
377
     * expected to come from before/after blocks of yaml (eg. framwork/*).
378
     *
379
     * @param string $pattern
380
     * @param array
381
     *
382
     * @return array
383
     */
384 6
    protected function getMatchingDocuments($pattern, $documents)
385
    {
386
        // If the pattern exists as a document name then its just a simple name match
387
        // and we can return that single document.
388 6
        if (isset($documents[$pattern])) {
389 4
            return [$documents[$pattern]];
390
        }
391
392
        // If the pattern starts with a hash, it means we're looking for a single document
393
        // named without the hash.
394 2
        if (strpos($pattern, '#') === 0) {
395 1
            $name = substr($pattern, 1);
396 1
            if (isset($documents[$name])) {
397 1
                return [$documents[$name]];
398
            }
399
400
            return [];
401
        }
402
403
        // If we only have an astericks, we'll add all unnamed docs. By excluding named docs
404
        // we don't run into a circular depndency issue.
405 1
        if($pattern === '*') {
406
            $pattern = 'anonymous-*';
407
        }
408
409
        // Do pattern matching on file names. This requires us to loop through each document
410
        // and check their filename and maybe their document name, depending on the pattern.
411
        // We don't want to do any pattern matching after the first hash as the document name
412
        // is assumed to follow it.
413 1
        $firstHash = strpos('#', $pattern);
414 1
        $documentName = false;
415 1
        if ($firstHash !== false) {
416
            $documentName = substr($pattern, $firstHash + 1);
417
            $pattern = substr($pattern, 0, $firstHash);
418
        }
419
420
        // @todo better sanitisation needed for special chars (chars used by preg_match())
421 1
        $pattern = str_replace(DIRECTORY_SEPARATOR, '\\'.DIRECTORY_SEPARATOR, $pattern);
422 1
        $pattern = str_replace('*', '[^\.][a-zA-Z0-9\-_\/\.]+', $pattern);
423
424 1
        $matchedDocuments = [];
425 1
        foreach ($documents as $document) {
426
            // Ensure filename is relative
427 1
            $filename = $this->makeRelative($document['filename']);
428 1
            if (preg_match('%^'.$pattern.'%', $filename)) {
429 1
                if ($documentName && $documentName != $document['header']['name']) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $documentName of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
430
                    // If we're looking for a specific document. If not found we can continue
431
                    continue;
432
                }
433
434 1
                $matchedDocuments[] = $document;
435 1
            }
436 1
        }
437
438 1
        return $matchedDocuments;
439
    }
440
441
    /**
442
     * We need this to make the path relative from the base directory. We can't use realpath
443
     * or relative path in Finder because we use a virtual filesystem in testing which
444
     * doesn't support these methods.
445
     *
446
     * @param string $filename
447
     *
448
     * @return string
449
     */
450 1
    protected function makeRelative($filename)
451
    {
452 1
        $dir = substr($filename, 0, strlen($this->baseDirectory));
453 1
        if ($dir == $this->baseDirectory) {
454 1
            return trim(substr($filename, strlen($this->baseDirectory)), DIRECTORY_SEPARATOR);
455
        }
456
457
        return trim($filename, DIRECTORY_SEPARATOR);
458
    }
459
460
    /**
461
     * This method gets all headers and all yaml documents and stores them respectively.
462
     *
463
     * @return array a list of sorted yaml documents
464
     */
465 14
    protected function getSortedYamlDocuments()
466
    {
467 14
        $documents = $this->getNamedYamlDocuments();
468 14
        $documents = $this->filterByOnlyAndExcept($documents);
469
470 14
        if (empty($documents)) {
471 1
            return [];
472
        }
473
474 13
        $dependencies = $this->calculateDependencies($documents);
475
476
        // Now that we've built up our dependencies, we can pass them into
477
        // a topological sort and return the headers.
478 13
        $sorter = new ArraySort();
479 13
        $sorter->set($dependencies);
480 13
        $sorted = $sorter->sort();
481
482 12
        $orderedDocuments = [];
483 12
        foreach ($sorted as $name) {
484 12
            if (!empty($documents[$name])) {
485 12
                $orderedDocuments[$name] = $documents[$name];
486 12
            }
487 12
        }
488
489 12
        return $orderedDocuments;
490
    }
491
492 14
    protected function filterByOnlyAndExcept($documents)
493
    {
494 14
        $filtered = [];
495 14
        foreach($documents as $key => $document) {
496 13 View Code Duplication
            if(isset($document['header'][self::ONLY_FLAG])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
497
                // If not all rules match, then we exclude this document
498 5
                if(!$this->testRules($document['header'], self::ONLY_FLAG)) {
499 2
                    continue;
500
                }
501 5
            }
502
503 13 View Code Duplication
            if(isset($document['header'][self::EXCEPT_FLAG])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
504
                // If all rules pass, then we exclude this document
505 5
                if($this->testRules($document['header'], self::EXCEPT_FLAG)) {
506 3
                    continue;
507
                }
508 2
            }
509
510 13
            $filtered[$key] = $document;
511 14
        }
512
513 14
        return $filtered;
514
    }
515
516 5
    protected function testRules($header, $flag)
517
    {
518 5
        if(!is_array($header[$flag])) {
519
            throw new Exception(sprintf('\'%s\' statements must be an array', $flag));
520
        }
521
522 5
        foreach($header[$flag] as $rule => $params) {
523 5
            if($this->isRuleIgnored($rule)) {
524
                // If checking only, then return true. Otherwise, return false.
525 1
                return $flag === self::ONLY_FLAG;
526
            }
527
528 4
            if(!$this->testSingleRule($rule, $params)) {
529 2
                return false;
530
            }
531 4
        }
532
533 4
        return true;
534
    }
535
536
    /**
537
     * Tests a rule against the given expected result.
538
     *
539
     * @param string $rule
540
     * @param string|array $params
541
     *
542
     * @return boolean
543
     */
544 4
    protected function testSingleRule($rule, $params)
545
    {
546 4
        if(!$this->hasRule($rule)) {
547
            throw new Exception(sprintf('Rule \'%s\' doesn\'t exist.', $rule));
548
        }
549
550 4
        if(!is_array($params)) {
551 3
            return $this->rules[$rule]($params);
552
        }
553
554
        // If its an array, we'll loop through each parameter
555 1
        foreach($params as $key => $value) {
556 1
            if(!$this->rules[$rule]($key, $value)) {
557 1
                return false;
558
            }
559 1
        }
560
561 1
        return true;
562
    }
563
}
564