Completed
Push — master ( 5e98ae...0ba1b3 )
by Vladimir
02:43
created

Parser::getFrontMatterVariables()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 10
ccs 4
cts 4
cp 1
crap 1
rs 9.4285
c 0
b 0
f 0
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 allejo\stakx\FrontMatter\Exception\YamlUnsupportedVariableException;
11
use allejo\stakx\FrontMatter\Exception\YamlVariableUndefinedException;
12
use allejo\stakx\Utilities\ArrayUtilities;
13
14
class Parser
15
{
16
    /**
17
     * The RegEx used to identify Front Matter variables.
18
     */
19
    const VARIABLE_DEF = '/(?<!\\\\)%([a-zA-Z]+)/';
20
21
    /**
22
     * A list of special fields in the Front Matter that will support expansion.
23
     *
24
     * @var string[]
25
     */
26
    private static $expandableFields = array('permalink');
27
28
    /**
29
     * Whether or not an field was expanded into several values.
30
     *
31
     * Only fields specified in $expandableFields will cause this value to be set to true
32
     *
33
     * @var bool
34
     */
35
    private $expansionUsed;
36
37
    /**
38
     * The current depth of the recursion for evaluating nested arrays in the Front Matter.
39
     *
40
     * @var int
41
     */
42
    private $nestingLevel;
43
44
    /**
45
     * Special FrontMatter keys that are defined manually.
46
     *
47
     * @var array
48
     */
49
    private $specialKeys;
50
51
    /**
52
     * The current hierarchy of the keys that are being evaluated.
53
     *
54
     * Since arrays can be nested, we'll keep track of the keys up until the current depth. This information is used for
55
     * error reporting
56
     *
57
     * @var array
58
     */
59
    private $yamlKeys;
60
61
    /**
62
     * The entire Front Matter block; evaluation will happen in place.
63
     *
64
     * @var array
65
     */
66
    private $frontMatter;
67
68 47
    public function __construct(array &$rawFrontMatter, array $specialKeys = array())
69
    {
70 47
        $this->expansionUsed = false;
71 47
        $this->nestingLevel = 0;
72 47
        $this->specialKeys = $specialKeys;
73 47
        $this->yamlKeys = array();
74
75 47
        $this->frontMatter = &$rawFrontMatter;
76
77 47
        $this->handleSpecialFrontMatter();
78 47
        $this->evaluateBlock($this->frontMatter);
79 42
    }
80
81
    /**
82
     * True if any fields were expanded in the FrontMatter block.
83
     *
84
     * @return bool
85
     */
86 20
    public function hasExpansion()
87
    {
88 20
        return $this->expansionUsed;
89
    }
90
91
    //
92
    // Special FrontMatter fields
93
    //
94
95
    /**
96
     * Special treatment for some FrontMatter variables.
97
     */
98 47
    private function handleSpecialFrontMatter()
99
    {
100 47
        $this->handleSpecialKeys();
101 47
        $this->handleDateField();
102 47
    }
103
104
    /**
105
     * Merge in the special keys with the existing FrontMatter.
106
     */
107 47
    private function handleSpecialKeys()
108
    {
109 47
        $this->frontMatter = array_merge($this->frontMatter, $this->specialKeys);
110 47
    }
111
112
    /**
113
     * Special treatment for the `date` field in FrontMatter that creates three new variables: year, month, day.
114
     */
115 47
    private function handleDateField()
116
    {
117 47
        if (!isset($this->frontMatter['date']))
118
        {
119 40
            return;
120
        }
121
122 7
        $date = &$this->frontMatter['date'];
123 7
        $itemDate = $this->guessDateTime($date);
124
125 7
        if (!$itemDate === false)
126
        {
127 7
            $this->frontMatter['date'] = $itemDate->format('U');
128 7
            $this->frontMatter['year'] = $itemDate->format('Y');
129 7
            $this->frontMatter['month'] = $itemDate->format('m');
130 7
            $this->frontMatter['day'] = $itemDate->format('d');
131
        }
132 7
    }
133
134
    //
135
    // Evaluation
136
    //
137
138
    /**
139
     * Evaluate an array as Front Matter.
140
     *
141
     * @param array $yaml
142
     */
143 47
    private function evaluateBlock(&$yaml)
144
    {
145 47
        ++$this->nestingLevel;
146
147 47
        foreach ($yaml as $key => &$value)
148
        {
149 47
            $this->yamlKeys[$this->nestingLevel] = $key;
150 47
            $keys = implode('.', $this->yamlKeys);
151
152 47
            if (in_array($key, self::$expandableFields, true))
153
            {
154 25
                $value = $this->evaluateExpandableField($keys, $value);
155
            }
156 47
            elseif (is_array($value))
157
            {
158 17
                $this->evaluateBlock($value);
159
            }
160 47
            elseif (is_string($value))
161
            {
162 47
                $value = $this->evaluateBasicType($keys, $value);
163
            }
164
            elseif ($value instanceof \DateTime)
165
            {
166 45
                $value = $this->castDateTimeTimezone($value->format('U'));
167
            }
168
        }
169
170 44
        --$this->nestingLevel;
171 44
        $this->yamlKeys = array();
172 44
    }
173
174
    /**
175
     * Evaluate an expandable field.
176
     *
177
     * @param string $key
178
     * @param string $fmStatement
179
     *
180
     * @return array
181
     */
182 25
    private function evaluateExpandableField($key, $fmStatement)
183
    {
184 25
        if (!is_array($fmStatement))
185
        {
186 24
            $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...
187
        }
188
189 25
        $wip = array();
190
191 25
        foreach ($fmStatement as $statement)
192
        {
193 25
            $value = $this->evaluateBasicType($key, $statement, true);
194
195
            // Only continue expansion if there are Front Matter variables remain in the string, this means there'll be
196
            // Front Matter variables referencing arrays
197 25
            $expandingVars = $this->getFrontMatterVariables($value);
198 25
            if (!empty($expandingVars))
199
            {
200 6
                $value = $this->evaluateArrayType($key, $value, $expandingVars);
201
            }
202
203 24
            $wip[] = $value;
204
        }
205
206 24
        return $wip;
207
    }
208
209
    /**
210
     * Convert a string or an array into an array of ExpandedValue objects created through "value expansion".
211
     *
212
     * @param string $frontMatterKey The current hierarchy of the Front Matter keys being used
213
     * @param string $expandableValue The Front Matter value that will be expanded
214
     * @param string[] $arrayVariableNames The Front Matter variable names that reference arrays
215
     *
216
     * @throws YamlUnsupportedVariableException If a multidimensional array is given for value expansion
217
     *
218
     * @return array
219
     */
220 6
    private function evaluateArrayType($frontMatterKey, $expandableValue, $arrayVariableNames)
221
    {
222 6
        if (!is_array($expandableValue))
223
        {
224 6
            $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...
225
        }
226
227 6
        $this->expansionUsed = true;
228
229 6
        foreach ($arrayVariableNames as $variable)
230
        {
231 6
            if (ArrayUtilities::is_multidimensional($this->frontMatter[$variable]))
232
            {
233 1
                throw new YamlUnsupportedVariableException("Yaml array expansion is not supported with multidimensional arrays with `$variable` for key `$frontMatterKey`");
234
            }
235
236 5
            $wip = array();
237
238 5
            foreach ($expandableValue as &$statement)
239
            {
240 5
                foreach ($this->frontMatter[$variable] as $value)
241
                {
242 5
                    $evaluatedValue = ($statement instanceof ExpandedValue) ? clone $statement : new ExpandedValue($statement);
243 5
                    $evaluatedValue->setEvaluated(str_replace('%' . $variable, $value, $evaluatedValue->getEvaluated()));
244 5
                    $evaluatedValue->setIterator($variable, $value);
245
246 5
                    $wip[] = $evaluatedValue;
247
                }
248
            }
249
250 5
            $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...
251
        }
252
253 5
        return $expandableValue;
254
    }
255
256
    /**
257
     * Evaluate an string for FrontMatter variables and replace them with the corresponding values.
258
     *
259
     * @param string $key          The key of the Front Matter value
260
     * @param string $string       The string that will be evaluated
261
     * @param bool   $ignoreArrays When set to true, an exception won't be thrown when an array is found with the
262
     *                             interpolation
263
     *
264
     * @throws YamlUnsupportedVariableException A FrontMatter variable is not an int, float, or string
265
     *
266
     * @return string The final string with variables evaluated
267
     */
268 47
    private function evaluateBasicType($key, $string, $ignoreArrays = false)
269
    {
270 47
        $variables = $this->getFrontMatterVariables($string);
271
272 47
        foreach ($variables as $variable)
273
        {
274 28
            $value = $this->getVariableValue($key, $variable);
275
276 26
            if (is_array($value) || is_bool($value))
277
            {
278 8
                if ($ignoreArrays)
279
                {
280 6
                    continue;
281
                }
282
283 2
                throw new YamlUnsupportedVariableException("Yaml variable `$variable` for `$key` is not a supported data type.");
284
            }
285
286 18
            $string = str_replace('%' . $variable, $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...
287
        }
288
289 44
        return $string;
290
    }
291
292
    //
293
    // Variable management
294
    //
295
296
    /**
297
     * Get an array of FrontMatter variables in the specified string that need to be interpolated.
298
     *
299
     * @param string $string
300
     *
301
     * @return string[]
302
     */
303 47
    private function getFrontMatterVariables($string)
304
    {
305 47
        $variables = array();
306
307 47
        preg_match_all(self::VARIABLE_DEF, $string, $variables);
308
309
        // Default behavior causes $variables[0] is the entire string that was matched. $variables[1] will be each
310
        // matching result individually.
311 47
        return $variables[1];
312
    }
313
314
    /**
315
     * Get the value of a FM variable or throw an exception.
316
     *
317
     * @param string $key
318
     * @param string $varName
319
     *
320
     * @throws YamlVariableUndefinedException
321
     *
322
     * @return mixed
323
     */
324 28
    private function getVariableValue($key, $varName)
325
    {
326 28
        if (!isset($this->frontMatter[$varName]))
327
        {
328 2
            throw new YamlVariableUndefinedException("Yaml variable `$varName` is not defined for: $key");
329
        }
330
331 26
        return $this->frontMatter[$varName];
332
    }
333
334
    //
335
    // Utility functions
336
    //
337
338
    /**
339
     * @param string $epochTime
340
     *
341
     * @return bool|\DateTime
342
     */
343 6
    private function castDateTimeTimezone($epochTime)
344
    {
345 6
        $timezone = new \DateTimeZone(date_default_timezone_get());
346 6
        $value = \DateTime::createFromFormat('U', $epochTime);
347 6
        $value->setTimezone($timezone);
348
349 6
        return $value;
350
    }
351
352
    /**
353
     * @param $guess
354
     *
355
     * @return bool|\DateTime
356
     */
357 7
    private function guessDateTime($guess)
358
    {
359 7
        if ($guess instanceof \DateTime)
360
        {
361 1
            return $guess;
362
        }
363 6
        elseif (is_numeric($guess))
364
        {
365 2
            return $this->castDateTimeTimezone($guess);
366
        }
367
368
        try
369
        {
370 4
            return new \DateTime($guess);
371
        }
372
        catch (\Exception $e)
373
        {
374
            return false;
375
        }
376
    }
377
}
378