Completed
Push — master ( a41cf5...7efa4f )
by Vladimir
02:55
created

FrontMatterParser::handleSpecialFrontMatter()   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 0
Metric Value
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
crap 1
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\FrontMatter;
9
10
use __;
11
use allejo\stakx\FrontMatter\Exception\YamlUnsupportedVariableException;
12
use allejo\stakx\FrontMatter\Exception\YamlVariableUndefinedException;
13
use allejo\stakx\Utilities\ArrayUtilities;
14
15
/**
16
 * The parser tasked with sanitizing and evaluating Front Matter of its variables.
17
 *
18
 * stakx's Front Matter has support for "primitive" and "complex" variables. Primitive variables are variables defined
19
 * through the Front Matter itself whereas complex variables are injected from other sources (e.g. the site's main
20
 * _config.yml file).
21
 *
22
 * ## Primitive Variables
23
 *
24
 * **Syntax**: `%variableName`
25
 *
26
 * **Example**
27
 *
28
 * ```yaml
29
 * ---
30
 * variableName: Hello
31
 * output: %variableName world! # "Hello world!"
32
 * ---
33
 * ```
34
 *
35
 * ## Complex Variables
36
 *
37
 * **Syntax**: `%{site.section.value}`
38
 *
39
 * **Example**
40
 *
41
 * ```yaml
42
 * # _config.yml
43
 *
44
 * title: stakx's site
45
 * someParent:
46
 *   nestedValue: Toast
47
 * ```
48
 *
49
 * ```yaml
50
 * ---
51
 * output: Hello from, %{site.title} # "Hello from, stakx's site"
52
 * title: I want %{site.someParent.nestedValue} # "I want Toast"
53
 * ---
54
 * ```
55
 *
56
 * @since 0.2.0 Add support for complex variables.
57
 * @since 0.1.0
58
 */
59
class FrontMatterParser
60
{
61
    /**
62
     * The RegEx used to identify Front Matter variables.
63
     */
64
    const VARIABLE_DEF = '/(?<!\\\\)%([a-zA-Z]+)/';
65
66
    /**
67
     * The RegEx used to identify special variables.
68
     */
69
    const ARRAY_DEF = '/(?<!\\\\)%{([a-zA-Z\.]+)}/';
70
71
    /**
72
     * A list of special fields in the Front Matter that will support expansion.
73
     *
74
     * @var string[]
75
     */
76
    private static $expandableFields = ['permalink'];
77
78
    /**
79
     * Whether or not an field was expanded into several values.
80
     *
81
     * Only fields specified in $expandableFields will cause this value to be set to true
82
     *
83
     * @var bool
84
     */
85
    private $expansionUsed;
86
87
    /**
88
     * The current depth of the recursion for evaluating nested arrays in the Front Matter.
89
     *
90
     * @var int
91
     */
92
    private $nestingLevel;
93
94
    /**
95
     * Special FrontMatter keys that are defined manually.
96
     *
97
     * @var array
98
     */
99
    private $specialKeys;
100
101
    /**
102
     * The current hierarchy of the keys that are being evaluated.
103
     *
104
     * Since arrays can be nested, we'll keep track of the keys up until the current depth. This information is used for
105
     * error reporting
106
     *
107
     * @var array
108
     */
109
    private $yamlKeys;
110
111
    /**
112
     * The entire Front Matter block; evaluation will happen in place.
113
     *
114
     * @var array
115
     */
116
    private $frontMatter;
117
118
    /**
119
     * YAML data that is being imported from external sources.
120
     *
121
     * @var array
122
     */
123
    private $complexVariables;
124
125
    /**
126
     * @param array $rawFrontMatter The array representation of a document's Front Matter
127
     * @param array $specialKeys    Front Matter variables defined manually, which will override any values defined
128
     *                              through Front Matter.
129
     */
130 113
    public function __construct(array &$rawFrontMatter, array $specialKeys = array())
131
    {
132 113
        $this->expansionUsed = false;
133 113
        $this->nestingLevel = 0;
134 113
        $this->specialKeys = $specialKeys;
135 113
        $this->yamlKeys = [];
136
137 113
        $this->frontMatter = &$rawFrontMatter;
138 113
        $this->complexVariables = [];
139 113
    }
140
141
    /**
142
     * Make complex variables available to the parser.
143
     *
144
     * @param array $yaml
145
     */
146 95
    public function addComplexVariables(array $yaml)
147
    {
148 95
        $this->complexVariables = array_merge($this->complexVariables, $yaml);
149 95
    }
150
151
    /**
152
     * Trigger the parsing functionality. The given array will be evaluated in place.
153
     */
154 113
    public function parse()
155
    {
156 113
        $this->handleSpecialFrontMatter();
157 113
        $this->evaluateBlock($this->frontMatter);
158 107
    }
159
160
    /**
161
     * True if any fields were expanded in the FrontMatter block.
162
     *
163
     * @return bool
164
     */
165 49
    public function hasExpansion()
166
    {
167 49
        return $this->expansionUsed;
168
    }
169
170
    //
171
    // Special FrontMatter fields
172
    //
173
174
    /**
175
     * Special treatment for some FrontMatter variables.
176
     */
177 113
    private function handleSpecialFrontMatter()
178
    {
179 113
        $this->handleSpecialKeys();
180 113
        $this->handleDateField();
181 113
    }
182
183
    /**
184
     * Merge in the special keys with the existing FrontMatter.
185
     */
186 113
    private function handleSpecialKeys()
187
    {
188 113
        $this->frontMatter = array_merge($this->frontMatter, $this->specialKeys);
189 113
    }
190
191
    /**
192
     * Special treatment for the `date` field in FrontMatter that creates three new variables: year, month, day.
193
     */
194 113
    private function handleDateField()
195
    {
196 113
        if (!isset($this->frontMatter['date']))
197
        {
198 105
            return;
199
        }
200
201 8
        $date = &$this->frontMatter['date'];
202 8
        $itemDate = $this->guessDateTime($date);
203
204 8
        if (!$itemDate === false)
205
        {
206 7
            $this->frontMatter['date'] = $itemDate->format('U');
207 7
            $this->frontMatter['year'] = $itemDate->format('Y');
208 7
            $this->frontMatter['month'] = $itemDate->format('m');
209 7
            $this->frontMatter['day'] = $itemDate->format('d');
210
        }
211 8
    }
212
213
    //
214
    // Evaluation
215
    //
216
217
    /**
218
     * Evaluate an array as Front Matter.
219
     *
220
     * @param array $yaml
221
     */
222 113
    private function evaluateBlock(&$yaml)
223
    {
224 113
        ++$this->nestingLevel;
225
226 113
        foreach ($yaml as $key => &$value)
227
        {
228 113
            $this->yamlKeys[$this->nestingLevel] = $key;
229 113
            $keys = implode('.', $this->yamlKeys);
230
231 113
            if (in_array($key, self::$expandableFields, true))
232
            {
233 46
                $value = $this->evaluateExpandableField($keys, $value);
234
            }
235 112
            elseif (is_array($value))
236
            {
237 52
                $this->evaluateBlock($value);
238
            }
239 112
            elseif (is_string($value))
240
            {
241 112
                $value = $this->evaluateBasicType($keys, $value);
242
            }
243 52
            elseif ($value instanceof \DateTime)
244
            {
245 110
                $value = $this->castDateTimeTimezone($value->format('U'));
246
            }
247
        }
248
249 109
        --$this->nestingLevel;
250 109
        $this->yamlKeys = array();
251 109
    }
252
253
    /**
254
     * Evaluate an expandable field.
255
     *
256
     * @param string $key
257
     * @param string $fmStatement
258
     *
259
     * @return array
260
     */
261 46
    private function evaluateExpandableField($key, $fmStatement)
262
    {
263 46
        if (!is_array($fmStatement))
264
        {
265 44
            $fmStatement = array($fmStatement);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $fmStatement. This often makes code more readable.
Loading history...
266
        }
267
268 46
        $wip = array();
269
270 46
        foreach ($fmStatement as $statement)
271
        {
272 46
            $value = $this->evaluateBasicType($key, $statement, true);
273
274
            // Only continue expansion if there are Front Matter variables remain in the string, this means there'll be
275
            // Front Matter variables referencing arrays
276 46
            $expandingVars = $this->findFrontMatterVariables($value);
277 46
            if (!empty($expandingVars))
278
            {
279 7
                $value = $this->evaluateArrayType($key, $value, $expandingVars);
280
            }
281
282 45
            $wip[] = $value;
283
        }
284
285 45
        return $wip;
286
    }
287
288
    /**
289
     * Convert a string or an array into an array of ExpandedValue objects created through "value expansion".
290
     *
291
     * @param string $frontMatterKey The current hierarchy of the Front Matter keys being used
292
     * @param string $expandableValue The Front Matter value that will be expanded
293
     * @param string[] $arrayVariableNames The Front Matter variable names that reference arrays
294
     *
295
     * @throws YamlUnsupportedVariableException If a multidimensional array is given for value expansion
296
     *
297
     * @return array
298
     */
299 7
    private function evaluateArrayType($frontMatterKey, $expandableValue, $arrayVariableNames)
300
    {
301 7
        if (!is_array($expandableValue))
302
        {
303 7
            $expandableValue = array($expandableValue);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $expandableValue. This often makes code more readable.
Loading history...
304
        }
305
306 7
        $this->expansionUsed = true;
307
308 7
        foreach ($arrayVariableNames as $variable)
309
        {
310 7
            $variableValue = $this->getVariableValue($frontMatterKey, $variable);
311
312 7
            if (ArrayUtilities::is_multidimensional($variableValue))
313
            {
314 1
                throw new YamlUnsupportedVariableException("Yaml array expansion is not supported with multidimensional arrays with `$variable` for key `$frontMatterKey`");
315
            }
316
317 6
            $wip = array();
318
319 6
            foreach ($expandableValue as &$statement)
320
            {
321 6
                foreach ($variableValue as $value)
322
                {
323 6
                    $evaluatedValue = ($statement instanceof ExpandedValue) ? clone $statement : new ExpandedValue($statement);
324
325 6
                    $varTemplate = $this->getVariableTemplate($variable);
326
327 6
                    $evaluatedValue->setEvaluated(str_replace($varTemplate, $value, $evaluatedValue->getEvaluated()));
328 6
                    $evaluatedValue->setIterator($variable, $value);
329
330 6
                    $wip[] = $evaluatedValue;
331
                }
332
            }
333
334 6
            $expandableValue = $wip;
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $expandableValue. This often makes code more readable.
Loading history...
335
        }
336
337 6
        return $expandableValue;
338
    }
339
340
    /**
341
     * Evaluate an string for FrontMatter variables and replace them with the corresponding values.
342
     *
343
     * @param string $key          The key of the Front Matter value
344
     * @param string $string       The string that will be evaluated
345
     * @param bool   $ignoreArrays When set to true, an exception won't be thrown when an array is found with the
346
     *                             interpolation
347
     *
348
     * @throws YamlUnsupportedVariableException A FrontMatter variable is not an int, float, or string
349
     *
350
     * @return string The final string with variables evaluated
351
     */
352 113
    private function evaluateBasicType($key, $string, $ignoreArrays = false)
353
    {
354 113
        $variables = $this->findFrontMatterVariables($string);
355
356 113
        foreach ($variables as $variable)
357
        {
358 31
            $value = $this->getVariableValue($key, $variable);
359
360 28
            if (is_array($value) || is_bool($value))
361
            {
362 9
                if ($ignoreArrays)
363
                {
364 7
                    continue;
365
                }
366
367 2
                throw new YamlUnsupportedVariableException("Yaml variable `$variable` for `$key` is not a supported data type.");
368
            }
369
370
            // The FM variable template that we need to replace with our evaluated value
371 19
            $varTemplate = $this->getVariableTemplate($variable);
372 19
            $string = str_replace($varTemplate, $value, $string);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $string. This often makes code more readable.
Loading history...
373
        }
374
375 109
        return $string;
376
    }
377
378
    //
379
    // Variable management
380
    //
381
382
    /**
383
     * Get an array of FrontMatter variables in the specified string that need to be interpolated.
384
     *
385
     * @param string $string
386
     *
387
     * @return string[]
388
     */
389 113
    private function findFrontMatterVariables($string)
390
    {
391 113
        $primitiveVars = [];
392 113
        preg_match_all(self::VARIABLE_DEF, $string, $primitiveVars);
393
394 113
        $complexVars = [];
395 113
        preg_match_all(self::ARRAY_DEF, $string, $complexVars);
396
397
        // Default behavior causes $primitiveVars[0] is the entire string that was matched. $primitiveVars[1] will be each
398
        // matching result individually.
399 113
        return array_merge($primitiveVars[1], $complexVars[1]);
400
    }
401
402
    /**
403
     * Get the value of a FM variable.
404
     *
405
     * @param string $key     The FM key that is being currently evaluated (used solely for a helpful error message)
406
     * @param string $varName The variable name we're searching for without the `%`
407
     *
408
     * @throws YamlVariableUndefinedException When variable is not defined.
409
     *
410
     * @return mixed
411
     */
412 31
    private function getVariableValue($key, $varName)
413
    {
414 31
        $isPrimitive = (strpos($varName, '.') === false);
415 31
        $variableVal = null;
0 ignored issues
show
Unused Code introduced by
$variableVal 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...
416
417 31
        if ($isPrimitive)
418
        {
419 28
            $variableVal = ArrayUtilities::array_safe_get($this->frontMatter, $varName);
420
        }
421
        else
422
        {
423 3
            $variableVal = __::get($this->complexVariables, $varName);
424
        }
425
426 31
        if ($variableVal === null)
427
        {
428 3
            throw new YamlVariableUndefinedException("Yaml variable `$varName` is not defined for: $key");
429
        }
430
431 28
        return $variableVal;
432
    }
433
434
    /**
435
     * Get the variable template that needs to be replaced.
436
     *
437
     * The syntax for primitive variables differ from complex variables, so this method will return the appropriate
438
     * template that will be used to replace the value.
439
     *
440
     * @param string $variableName The variable name
441
     *
442
     * @return string
443
     */
444 25
    private function getVariableTemplate($variableName)
445
    {
446 25
        $isPrimitive = (strpos($variableName, '.') === false);
447
448 25
        return ($isPrimitive) ? sprintf('%%%s', $variableName) : sprintf('%%{%s}', $variableName);
449
    }
450
451
    //
452
    // Utility functions
453
    //
454
455
    /**
456
     * @param string $epochTime
457
     *
458
     * @return bool|\DateTime
459
     */
460 6
    private function castDateTimeTimezone($epochTime)
461
    {
462 6
        $timezone = new \DateTimeZone(date_default_timezone_get());
463 6
        $value = \DateTime::createFromFormat('U', $epochTime);
464 6
        $value->setTimezone($timezone);
465
466 6
        return $value;
467
    }
468
469
    /**
470
     * @param $guess
471
     *
472
     * @return bool|\DateTime
473
     */
474 8
    private function guessDateTime($guess)
475
    {
476 8
        if ($guess instanceof \DateTime)
477
        {
478 1
            return $guess;
479
        }
480 7
        elseif (is_numeric($guess))
481
        {
482 2
            return $this->castDateTimeTimezone($guess);
483
        }
484
485
        try
486
        {
487 5
            return new \DateTime($guess);
488
        }
489 1
        catch (\Exception $e)
490
        {
491 1
            return false;
492
        }
493
    }
494
}
495