Completed
Pull Request — master (#41)
by Vladimir
02:31
created

FrontMatterObject   C

Complexity

Total Complexity 61

Size/Duplication

Total Lines 586
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 8

Test Coverage

Coverage 82.21%

Importance

Changes 0
Metric Value
dl 0
loc 586
ccs 134
cts 163
cp 0.8221
rs 6.018
c 0
b 0
f 0
wmc 61
lcom 1
cbo 8

26 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 17 2
getContent() 0 1 ?
A getExtension() 0 4 1
A getFilePath() 0 4 1
A getLineOffset() 0 4 1
A getName() 0 4 1
A getRelativeFilePath() 0 10 2
A getTargetFile() 0 13 2
A hasTwigDependency() 0 4 1
C refreshFileContent() 0 47 7
A findTwigDataDependencies() 0 9 1
C getPermalink() 0 33 7
A getRedirects() 0 9 2
B getPathPermalink() 0 24 3
B sanitizePermalink() 0 27 2
A evaluateFrontMatter() 0 8 2
A getFrontMatter() 0 13 4
A hasExpandedFrontMatter() 0 4 2
A appendFrontMatter() 0 7 2
A deleteFrontMatter() 0 9 2
A setFrontMatter() 0 9 2
A evaluateYaml() 0 12 2
A offsetSet() 0 9 2
A offsetExists() 0 11 4
A offsetUnset() 0 4 1
B offsetGet() 0 21 5

How to fix   Complexity   

Complex Class

Complex classes like FrontMatterObject often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FrontMatterObject, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * @copyright 2017 Vladimir Jimenez
5
 * @license   https://github.com/allejo/stakx/blob/master/LICENSE.md MIT
6
 */
7
8
namespace allejo\stakx\Object;
9
10
use allejo\stakx\Exception\FileAwareException;
11
use allejo\stakx\Exception\InvalidSyntaxException;
12
use allejo\stakx\FrontMatter\FrontMatterParser;
13
use allejo\stakx\FrontMatter\YamlVariableUndefinedException;
14
use allejo\stakx\System\Filesystem;
15
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
16
use Symfony\Component\Filesystem\Exception\IOException;
17
use Symfony\Component\Finder\SplFileInfo;
18
use Symfony\Component\Yaml\Exception\ParseException;
19
use Symfony\Component\Yaml\Yaml;
20
21
abstract class FrontMatterObject implements FrontMatterable, Jailable, \ArrayAccess
22
{
23
    protected static $whiteListFunctions = array(
24
        'getPermalink', 'getRedirects', 'getTargetFile', 'getName', 'getFilePath', 'getRelativeFilePath', 'getContent',
25
        'getExtension', 'getFrontMatter'
26
    );
27
28
    /**
29
     * An array to keep track of collection or data dependencies used inside of a Twig template.
30
     *
31
     * $dataDependencies['collections'] = array()
32
     * $dataDependencies['data'] = array()
33
     *
34
     * @var array
35
     */
36
    protected $dataDependencies;
37
38
    /**
39
     * FrontMatter values that can be injected or set after the file has been parsed. Values in this array will take
40
     * precedence over values in $frontMatter.
41
     *
42
     * @var array
43
     */
44
    protected $writableFrontMatter;
45
46
    /**
47
     * A list of Front Matter values that should not be returned directly from the $frontMatter array. Values listed
48
     * here have dedicated functions that handle those Front Matter values and the respective functions should be called
49
     * instead.
50
     *
51
     * @var string[]
52
     */
53
    protected $frontMatterBlacklist;
54
55
    /**
56
     * Set to true if the front matter has already been evaluated with variable interpolation.
57
     *
58
     * @var bool
59
     */
60
    protected $frontMatterEvaluated;
61
62
    /**
63
     * @var FrontMatterParser
64
     */
65
    protected $frontMatterParser;
66
67
    /**
68
     * An array containing the Yaml of the file.
69
     *
70
     * @var array
71
     */
72
    protected $frontMatter;
73
74
    /**
75
     * Set to true if the body has already been parsed as markdown or any other format.
76
     *
77
     * @var bool
78
     */
79
    protected $bodyContentEvaluated;
80
81
    /**
82
     * Only the body of the file, i.e. the content.
83
     *
84
     * @var string
85
     */
86
    protected $bodyContent;
87
88
    /**
89
     * The permalink for this object.
90
     *
91
     * @var string
92
     */
93
    protected $permalink;
94
95
    /**
96
     * A filesystem object.
97
     *
98
     * @var Filesystem
99
     */
100
    protected $fs;
101
102
    /**
103
     * The extension of the file.
104
     *
105
     * @var string
106
     */
107
    private $extension;
108
109
    /**
110
     * The number of lines that Twig template errors should offset.
111
     *
112
     * @var int
113
     */
114
    private $lineOffset;
115
116
    /**
117
     * A list URLs that will redirect to this object.
118
     *
119
     * @var string[]
120
     */
121
    private $redirects;
122
123
    /**
124
     * The original file path to the ContentItem.
125
     *
126
     * @var SplFileInfo
127
     */
128
    private $filePath;
129
130
    /**
131
     * ContentItem constructor.
132
     *
133
     * @param string $filePath The path to the file that will be parsed into a ContentItem
134
     *
135
     * @throws FileNotFoundException The given file path does not exist
136
     * @throws IOException           The file was not a valid ContentItem. This would meam there was no front matter or
137
     *                               no body
138
     */
139 109
    public function __construct($filePath)
140
    {
141 109
        $this->frontMatterBlacklist = array('permalink', 'redirects');
142 109
        $this->writableFrontMatter = array();
143
144 109
        $this->filePath = $filePath;
0 ignored issues
show
Documentation Bug introduced by
It seems like $filePath of type string is incompatible with the declared type object<Symfony\Component\Finder\SplFileInfo> of property $filePath.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
145 109
        $this->fs = new Filesystem();
146
147 109
        if (!$this->fs->exists($filePath))
148 109
        {
149 1
            throw new FileNotFoundException("The following file could not be found: ${filePath}");
150
        }
151
152
        $this->extension = strtolower($this->fs->getExtension($filePath));
153
154
        $this->refreshFileContent();
155
    }
156
157
    /**
158
     * Return the body of the Content Item.
159
     *
160
     * @return string
161
     */
162
    abstract public function getContent();
163
164
    /**
165
     * Get the extension of the current file.
166
     *
167
     * @return string
168
     */
169
    final public function getExtension()
170
    {
171 7
        return $this->extension;
172
    }
173
174
    /**
175
     * Get the original file path.
176
     *
177
     * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be SplFileInfo?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
178
     */
179
    final public function getFilePath()
180
    {
181 14
        return $this->filePath;
182
    }
183
184
    /**
185
     * The number of lines that are taken up by FrontMatter and white space.
186
     *
187
     * @return int
188
     */
189
    final public function getLineOffset()
190
    {
191
        return $this->lineOffset;
192
    }
193
194
    /**
195
     * Get the name of the item, which is just the file name without the extension.
196
     *
197
     * @return string
198
     */
199
    final public function getName()
200
    {
201 34
        return $this->fs->getBaseName($this->filePath);
202
    }
203
204
    /**
205
     * Get the relative path to this file relative to the root of the Stakx website.
206
     *
207
     * @return string
208
     */
209
    final public function getRelativeFilePath()
210
    {
211 50
        if ($this->filePath instanceof SplFileInfo)
212 50
        {
213 36
            return $this->filePath->getRelativePathname();
214
        }
215
216
        // TODO ensure that we always get SplFileInfo objects, even when handling VFS documents
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
217 14
        return $this->fs->getRelativePath($this->filePath);
218
    }
219
220
    /**
221
     * Get the destination of where this Content Item would be written to when the website is compiled.
222
     *
223
     * @return string
224
     */
225
    final public function getTargetFile()
226
    {
227 15
        $permalink = $this->getPermalink();
228 15
        $missingFile = (substr($permalink, -1) == '/');
229 15
        $permalink = str_replace('/', DIRECTORY_SEPARATOR, $permalink);
230
231
        if ($missingFile)
232 15
        {
233 7
            $permalink = rtrim($permalink, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'index.html';
234 7
        }
235
236 15
        return ltrim($permalink, DIRECTORY_SEPARATOR);
237
    }
238
239
    /**
240
     * Check whether this object has a reference to a collection or data item.
241
     *
242
     * @param string $namespace 'collections' or 'data'
243
     * @param string $needle
244
     *
245
     * @return bool
246
     */
247
    final public function hasTwigDependency($namespace, $needle)
248
    {
249
        return in_array($needle, $this->dataDependencies[$namespace]);
250
    }
251
252
    /**
253
     * Read the file, and parse its contents.
254
     */
255
    final public function refreshFileContent()
256
    {
257
        // This function can be called after the initial object was created and the file may have been deleted since the
258
        // creation of the object.
259 108
        if (!$this->fs->exists($this->filePath))
260 108
        {
261 1
            throw new FileNotFoundException(null, 0, null, $this->filePath);
262
        }
263
264 108
        $rawFileContents = file_get_contents($this->filePath);
265 108
        $fileStructure = array();
266 108
        preg_match('/---\R(.*?\R)?---(\s+)(.*)/s', $rawFileContents, $fileStructure);
267
268 108
        if (count($fileStructure) != 4)
269 108
        {
270 9
            throw new InvalidSyntaxException('Invalid FrontMatter file', 0, null, $this->getRelativeFilePath());
271
        }
272
273 99
        if (empty(trim($fileStructure[3])))
274 99
        {
275 1
            throw new InvalidSyntaxException('FrontMatter files must have a body to render', 0, null, $this->getRelativeFilePath());
276
        }
277
278 98
        $this->lineOffset = substr_count($fileStructure[1], "\n") + substr_count($fileStructure[2], "\n");
279 98
        $this->bodyContent = $fileStructure[3];
280
281 98
        if (!empty(trim($fileStructure[1])))
282 98
        {
283 82
            $this->frontMatter = Yaml::parse($fileStructure[1], Yaml::PARSE_DATETIME);
0 ignored issues
show
Documentation Bug introduced by
It seems like \Symfony\Component\Yaml\...l\Yaml::PARSE_DATETIME) can also be of type string or object<stdClass>. However, the property $frontMatter is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
284
285 82
            if (!empty($this->frontMatter) && !is_array($this->frontMatter))
286 82
            {
287 1
                throw new ParseException('The evaluated FrontMatter should be an array');
288
            }
289 81
        }
290
        else
291
        {
292 19
            $this->frontMatter = array();
293
        }
294
295 97
        $this->frontMatterEvaluated = false;
296 97
        $this->bodyContentEvaluated = false;
297 97
        $this->permalink = null;
298
299 97
        $this->findTwigDataDependencies('collections');
300 97
        $this->findTwigDataDependencies('data');
301 97
    }
302
303
    /**
304
     * Get all of the references to either DataItems or ContentItems inside of given string.
305
     *
306
     * @param string $filter 'collections' or 'data'
307
     */
308
    private function findTwigDataDependencies($filter)
309
    {
310 97
        $regex = '/{[{%](?:.+)?(?:' . $filter . ')(?:\.|\[\')(\w+)(?:\'\])?.+[%}]}/';
311 97
        $results = array();
312
313 97
        preg_match_all($regex, $this->bodyContent, $results);
314
315 97
        $this->dataDependencies[$filter] = array_unique($results[1]);
316 97
    }
317
318
    //
319
    // Permalink and redirect functionality
320
    //
321
322
    /**
323
     * Get the permalink of this Content Item.
324
     *
325
     * @throws \Exception
326
     *
327
     * @return string
328
     */
329
    final public function getPermalink()
330
    {
331 34
        if (!is_null($this->permalink))
332 34
        {
333 6
            return $this->permalink;
334
        }
335
336 34
        if (!is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion())
337 34
        {
338
            throw new \Exception('The permalink for this item has not been set');
339
        }
340
341 34
        $permalink = (is_array($this->frontMatter) && isset($this->frontMatter['permalink'])) ?
342 34
            $this->frontMatter['permalink'] : $this->getPathPermalink();
343
344 34
        if (is_array($permalink))
345 34
        {
346 17
            $this->permalink = $permalink[0];
347 17
            array_shift($permalink);
348 17
            $this->redirects = $permalink;
349 17
        }
350
        else
351
        {
352 21
            $this->permalink = $permalink;
353 21
            $this->redirects = array();
354
        }
355
356 34
        $this->permalink = $this->sanitizePermalink($this->permalink);
357 34
        $this->permalink = str_replace(DIRECTORY_SEPARATOR, '/', $this->permalink);
358 34
        $this->permalink = '/' . ltrim($this->permalink, '/'); // Permalinks should always use '/' and not be OS specific
359
360 34
        return $this->permalink;
361
    }
362
363
    /**
364
     * Get an array of URLs that will redirect to.
365
     *
366
     * @return string[]
0 ignored issues
show
Documentation introduced by
Should the return type not be null|string[]?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
367
     */
368
    final public function getRedirects()
369
    {
370 11
        if (is_null($this->redirects))
371 11
        {
372 1
            $this->getPermalink();
373 1
        }
374
375 11
        return $this->redirects;
376
    }
377
378
    /**
379
     * Get the permalink based off the location of where the file is relative to the website. This permalink is to be
380
     * used as a fallback in the case that a permalink is not explicitly specified in the Front Matter.
381
     *
382
     * @return string
383
     */
384
    private function getPathPermalink()
385
    {
386
        // Remove the protocol of the path, if there is one and prepend a '/' to the beginning
387 3
        $cleanPath = preg_replace('/[\w|\d]+:\/\//', '', $this->getRelativeFilePath());
388 3
        $cleanPath = ltrim($cleanPath, DIRECTORY_SEPARATOR);
389
390
        // Handle vfs:// paths by replacing their forward slashes with the OS appropriate directory separator
391 3
        if (DIRECTORY_SEPARATOR !== '/')
392 3
        {
393
            $cleanPath = str_replace('/', DIRECTORY_SEPARATOR, $cleanPath);
394
        }
395
396
        // Check the first folder and see if it's a data folder (starts with an underscore) intended for stakx
397 3
        $folders = explode(DIRECTORY_SEPARATOR, $cleanPath);
398
399 3
        if (substr($folders[0], 0, 1) === '_')
400 3
        {
401 1
            array_shift($folders);
402 1
        }
403
404 3
        $cleanPath = implode(DIRECTORY_SEPARATOR, $folders);
405
406 3
        return $cleanPath;
407
    }
408
409
    /**
410
     * Sanitize a permalink to remove unsupported characters or multiple '/' and replace spaces with hyphens.
411
     *
412
     * @param string $permalink A permalink
413
     *
414
     * @return string $permalink The sanitized permalink
415
     */
416
    private function sanitizePermalink($permalink)
417
    {
418
        // Remove multiple '/' together
419 34
        $permalink = preg_replace('/\/+/', '/', $permalink);
420
421
        // Replace all spaces with hyphens
422 34
        $permalink = str_replace(' ', '-', $permalink);
423
424
        // Remove all disallowed characters
425 34
        $permalink = preg_replace('/[^0-9a-zA-Z-_\/\\\.]/', '', $permalink);
426
427
        // Handle unnecessary extensions
428 34
        $extensionsToStrip = array('twig');
429
430 34
        if (in_array($this->fs->getExtension($permalink), $extensionsToStrip))
431 34
        {
432 4
            $permalink = $this->fs->removeExtension($permalink);
433 4
        }
434
435
        // Remove any special characters before a sane value
436 34
        $permalink = preg_replace('/^[^0-9a-zA-Z-_]*/', '', $permalink);
437
438
        // Convert permalinks to lower case
439 34
        $permalink = mb_strtolower($permalink, 'UTF-8');
440
441 34
        return $permalink;
442
    }
443
444
    //
445
    // WritableFrontMatter Implementation
446
    //
447
448
    /**
449
     * {@inheritdoc}
450
     */
451
    final public function evaluateFrontMatter($variables = null)
452
    {
453 5
        if (!is_null($variables))
454 5
        {
455 5
            $this->frontMatter = array_merge($this->frontMatter, $variables);
456 5
            $this->evaluateYaml($this->frontMatter);
457 5
        }
458 5
    }
459
460
    /**
461
     * {@inheritdoc}
462
     */
463
    final public function getFrontMatter($evaluateYaml = true)
464
    {
465 25
        if (is_null($this->frontMatter))
466 25
        {
467
            $this->frontMatter = array();
468
        }
469 25
        elseif (!$this->frontMatterEvaluated && $evaluateYaml)
470
        {
471 21
            $this->evaluateYaml($this->frontMatter);
472 20
        }
473
474 24
        return $this->frontMatter;
475
    }
476
477
    /**
478
     * {@inheritdoc}
479
     */
480
    final public function hasExpandedFrontMatter()
481
    {
482 2
        return !is_null($this->frontMatterParser) && $this->frontMatterParser->hasExpansion();
483
    }
484
485
    /**
486
     * {@inheritdoc.
487
     */
488
    final public function appendFrontMatter(array $frontMatter)
489
    {
490
        foreach ($frontMatter as $key => $value)
491
        {
492
            $this->writableFrontMatter[$key] = $value;
493
        }
494
    }
495
496
    /**
497
     * {@inheritdoc.
498
     */
499
    final public function deleteFrontMatter($key)
500
    {
501
        if (!isset($this->writableFrontMatter[$key]))
502
        {
503
            return;
504
        }
505
506
        unset($this->writableFrontMatter[$key]);
507
    }
508
509
    /**
510
     * {@inheritdoc.
511
     */
512
    final public function setFrontMatter(array $frontMatter)
513
    {
514
        if (!is_array($frontMatter))
515
        {
516
            throw new \InvalidArgumentException('An array is required for setting the writable FrontMatter');
517
        }
518
519
        $this->writableFrontMatter = $frontMatter;
520
    }
521
522
    /**
523
     * Evaluate an array of data for FrontMatter variables. This function will modify the array in place.
524
     *
525
     * @param array $yaml An array of data containing FrontMatter variables
526
     *
527
     * @throws YamlVariableUndefinedException A FrontMatter variable used does not exist
528
     */
529
    private function evaluateYaml(&$yaml)
530
    {
531
        try
532
        {
533 26
            $this->frontMatterParser = new FrontMatterParser($yaml);
534 25
            $this->frontMatterEvaluated = true;
535
        }
536 26
        catch (\Exception $e)
537
        {
538 1
            throw FileAwareException::castException($e, $this->getRelativeFilePath());
539
        }
540 25
    }
541
542
    //
543
    // ArrayAccess Implementation
544
    //
545
546
    /**
547
     * {@inheritdoc}
548
     */
549
    public function offsetSet($offset, $value)
550
    {
551
        if (is_null($offset))
552
        {
553
            throw new \InvalidArgumentException('$offset cannot be null');
554
        }
555
556
        $this->writableFrontMatter[$offset] = $value;
557
    }
558
559
    /**
560
     * {@inheritdoc}
561
     */
562
    public function offsetExists($offset)
563
    {
564 31
        if (isset($this->writableFrontMatter[$offset]) || isset($this->frontMatter[$offset]))
565 31
        {
566 30
            return true;
567
        }
568
569 9
        $fxnCall = 'get' . ucfirst($offset);
570
571 9
        return method_exists($this, $fxnCall) && in_array($fxnCall, static::$whiteListFunctions);
572
    }
573
574
    /**
575
     * {@inheritdoc}
576
     */
577
    public function offsetUnset($offset)
578
    {
579
        unset($this->writableFrontMatter[$offset]);
580
    }
581
582
    /**
583
     * {@inheritdoc}
584
     */
585
    public function offsetGet($offset)
586
    {
587 44
        $fxnCall = 'get' . ucfirst($offset);
588
589 44
        if (in_array($fxnCall, self::$whiteListFunctions) && method_exists($this, $fxnCall))
590 44
        {
591 6
            return call_user_func_array(array($this, $fxnCall), array());
592
        }
593
594 38
        if (isset($this->writableFrontMatter[$offset]))
595 38
        {
596
            return $this->writableFrontMatter[$offset];
597
        }
598
599 38
        if (isset($this->frontMatter[$offset]))
600 38
        {
601 37
            return $this->frontMatter[$offset];
602
        }
603
604 2
        return null;
605
    }
606
}
607