XmlAdapter::getXmlErrors()   A
last analyzed

Complexity

Conditions 4
Paths 2

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 13
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 20
rs 9.8333
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Biurad opensource projects.
7
 *
8
 * PHP version 7.2 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Biurad\DependencyInjection\Adapters;
19
20
use Closure;
21
use DOMComment;
22
use DOMDocument;
23
use DOMElement;
24
use DOMText;
25
use Exception;
26
use LogicException;
27
use Nette\DI;
28
use Nette\InvalidStateException;
29
use ReflectionObject;
30
use RuntimeException;
31
use stdClass;
32
use XMLWriter;
33
34
/**
35
 * Reading and generating XML/Html files for DI.
36
 *
37
 * @author Divine Niiquaye Ibok <[email protected]>
38
 */
39
final class XmlAdapter implements Di\Config\Adapter
40
{
41
    public function __construct()
42
    {
43
        if (!\extension_loaded('dom') && !\extension_loaded('xmlwriter')) {
44
            throw new LogicException('Extension DOM and XmlWriter is required.');
45
        }
46
    }
47
48
    /**
49
     * Parses an XML string.
50
     *
51
     * @param string               $content          An XML string
52
     * @param null|callable|string $schemaOrCallable An XSD schema file path, a callable, or null to disable validation
53
     *
54
     * @throws InvalidStateException When parsing of XML file returns error
55
     * @throws InvalidXmlException   When parsing of XML with schema or callable produces any errors
56
     *                               unrelated to the XML parsing itself
57
     * @throws RuntimeException      When DOM extension is missing
58
     *
59
     * @return DOMDocument
60
     */
61
    public function parse(string $content, $schemaOrCallable = null): DOMDocument
62
    {
63
        $internalErrors  = \libxml_use_internal_errors(true);
64
        \libxml_clear_errors();
65
66
        $dom                  = new DOMDocument();
67
        $dom->validateOnParse = true;
68
69
        if (!$dom->loadXML($content, \LIBXML_NONET | (\defined('LIBXML_COMPACT') ? \LIBXML_COMPACT : 0))) {
70
71
            $dom->loadHTML($content);
72
        }
73
74
        $dom->normalizeDocument();
75
76
        \libxml_use_internal_errors($internalErrors);
77
78
        if (null !== $schemaOrCallable) {
79
            $internalErrors = \libxml_use_internal_errors(true);
80
            \libxml_clear_errors();
81
82
            $e = null;
83
84
            if (\is_callable($schemaOrCallable)) {
85
                try {
86
                    $valid = $schemaOrCallable($dom, $internalErrors);
87
                } catch (Exception $e) {
88
                    $valid = false;
89
                }
90
            } elseif (!\is_array($schemaOrCallable) && \is_file((string) $schemaOrCallable)) {
91
                $schemaSource = \file_get_contents((string) $schemaOrCallable);
92
                $valid        = @$dom->schemaValidateSource($schemaSource);
93
            } else {
94
                \libxml_use_internal_errors($internalErrors);
95
96
                throw new InvalidStateException(
97
                    'The schemaOrCallable argument has to be a valid path to XSD file or callable.'
98
                );
99
            }
100
101
            if (!$valid) {
102
                $messages = $this->getXmlErrors($internalErrors);
103
104
                if (empty($messages)) {
105
                    throw new RuntimeException('The XML is not valid.', 0, $e);
106
                }
107
108
                throw new InvalidStateException(\implode("\n", $messages), 0, $e);
109
            }
110
        }
111
112
        \libxml_clear_errors();
113
        \libxml_use_internal_errors($internalErrors);
114
115
        return $dom;
116
    }
117
118
    /**
119
     * Converts a \DOMElement object to a PHP array.
120
     *
121
     * The following rules applies during the conversion:
122
     *
123
     *  * Each tag is converted to a key value or an array
124
     *    if there is more than one "value"
125
     *
126
     *  * The content of a tag is set under a "value" key (<foo>bar</foo>)
127
     *    if the tag also has some nested tags
128
     *
129
     *  * The attributes are converted to keys (<foo foo="bar"/>)
130
     *
131
     *  * The nested-tags are converted to keys (<foo><foo>bar</foo></foo>)
132
     *
133
     * @param DOMElement $element     A \DOMElement instance
134
     * @param bool       $checkPrefix Check prefix in an element or an attribute name
135
     *
136
     * @return mixed
137
     */
138
    public function convertDomElementToArray(DOMElement $element, bool $checkPrefix = true)
139
    {
140
        $prefix = (string) $element->prefix;
141
        $empty  = true;
142
        $config = [];
143
144
        foreach ($element->attributes as $name => $node) {
145
            if ($checkPrefix && !\in_array((string) $node->prefix, ['', $prefix], true)) {
146
                continue;
147
            }
148
            $config[$name] = $this->phpize($node->value);
149
            $empty         = false;
150
        }
151
152
        $nodeValue = false;
153
154
        foreach ($element->childNodes as $node) {
155
            if ($node instanceof DOMText) {
156
                if ('' !== \trim($node->nodeValue)) {
157
                    $nodeValue = \trim($node->nodeValue);
158
                    $empty     = false;
159
                }
160
            } elseif ($checkPrefix && $prefix != (string) $node->prefix) {
161
                continue;
162
            } elseif (!$node instanceof DOMComment) {
163
                $value = $this->convertDomElementToArray($node, $checkPrefix);
164
165
                $key = $node->localName;
166
167
                if (isset($config[$key])) {
168
                    if (!\is_array($config[$key]) || !\is_int(\key($config[$key]))) {
169
                        $config[$key] = [$config[$key]];
170
                    }
171
                    $config[$key][] = $value;
172
                } else {
173
                    $config[$key] = $value;
174
                }
175
176
                $empty = false;
177
            }
178
        }
179
180
        if (false !== $nodeValue) {
181
            $value = $this->phpize($nodeValue);
182
183
            if (\count($config)) {
184
                $config['value'] = $value;
185
            } else {
186
                $config = $value;
187
            }
188
        }
189
190
        return !$empty ? $config : null;
191
    }
192
193
    /**
194
     * Reads configuration from XML data.
195
     *
196
     * {@inheritdoc}
197
     */
198
    public function load(string $file): array
199
    {
200
        $content = $this->parse(\file_get_contents($file));
201
202
        return $this->convertDomElementToArray($content->documentElement);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->convertDom...ntent->documentElement) could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
203
    }
204
205
    /**
206
     * Generates configuration in XML format,
207
     * Process the array|objects for dumping.
208
     *
209
     * @param array $config
210
     *
211
     * @return string
212
     */
213
    public function dump(array $data): string
214
    {
215
        $writer = new XMLWriter();
216
        $writer->openMemory();
217
        $writer->setIndent(true);
218
        $writer->setIndentString(\str_repeat(' ', 4));
219
220
        if (isset($config['head'], $config['body'])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $config seems to never exist and therefore isset should always be false.
Loading history...
221
            $writer->writeRaw("<!Doctype html>\n");
222
            $writer->startElement('html');
223
        } else {
224
            $writer->startDocument('1.0', 'UTF-8');
225
            $writer->startElement('container');
226
        }
227
228
        $data = \array_map(function ($value) {
229
            $value = $this->resolveClassObject($value);
230
231
            if (\is_array($value)) {
232
                return \array_map([$this, 'resolveClassObject'], $value);
233
            }
234
235
            return $value;
236
        }, $data);
237
238
        foreach ($data as $sectionName => $config) {
239
            if (!\is_array($config)) {
240
                $writer->writeAttribute($sectionName, $this->xmlize($config));
241
            } else {
242
                $this->addBranch($sectionName, $config, $writer);
243
            }
244
        }
245
246
        $writer->endElement();
247
        $writer->endDocument();
248
249
        return $writer->outputMemory();
250
    }
251
252
    /**
253
     * Add a branch to an XML/Html object recursively.
254
     *
255
     * @param string    $branchName
256
     * @param array     $config
257
     * @param XMLWriter $writer
258
     *
259
     * @throws RuntimeException
260
     */
261
    protected function addBranch($branchName, array $config, XMLWriter $writer): void
262
    {
263
        $branchType = null;
264
265
        foreach ($config as $key => $value) {
266
            if ($branchType === null) {
267
                if (\is_numeric($key)) {
268
                    $branchType = 'numeric';
269
                } else {
270
                    $writer->startElement($branchName);
271
                    $branchType = 'string';
272
                }
273
            } elseif ($branchType !== (\is_numeric($key) ? 'numeric' : 'string')) {
274
                throw new RuntimeException('Mixing of string and numeric keys is not allowed');
275
            }
276
277
            if ($branchType === 'numeric') {
278
                if (\is_array($value) || $value instanceof stdClass) {
279
                    $this->addBranch($branchName, (array) $value, $writer);
280
                } else {
281
                    $writer->writeElement($branchName, $this->xmlize($value));
282
                }
283
            } else {
284
                if (\is_array($value)) {
285
                    if (\array_key_exists('value', $value)) {
286
                        $newValue = $value['value'];
287
                        unset($value['value']);
288
289
                        $writer->startElement($key);
290
                        \array_walk_recursive($value, function ($item, $id) use (&$writer): void {
291
                            $writer->writeAttribute($id, $item);
292
                        });
293
294
                        $writer->writeRaw($this->xmlize($newValue));
295
                        $writer->endElement();
296
                        $writer->endAttribute();
297
                    } else {
298
                        $this->addBranch($key, $value, $writer);
299
                    }
300
                } else {
301
                    $writer->writeAttribute($key, $this->xmlize($value));
302
                }
303
            }
304
        }
305
306
        if ($branchType === 'string') {
307
            $writer->endElement();
308
        }
309
    }
310
311
    /**
312
     * Converts an xml value to a PHP type.
313
     *
314
     * @param mixed $value
315
     *
316
     * @return mixed
317
     */
318
    protected function phpize($value)
319
    {
320
        $value          = (string) $value;
321
        $lowercaseValue = \strtolower($value);
322
323
        switch (true) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing preg_match('/^[+-]?[0-9]+(\.[0-9]+)?$/', $value) of type integer to the boolean true. If you are specifically checking for non-zero, consider using something more explicit like > 0 or !== 0 instead.
Loading history...
Bug Best Practice introduced by
It seems like you are loosely comparing preg_match('/^0x[0-9a-f]++$/i', $value) of type integer to the boolean true. If you are specifically checking for non-zero, consider using something more explicit like > 0 or !== 0 instead.
Loading history...
324
            case 'null' === $lowercaseValue:
325
                return null;
326
            case \ctype_digit($value):
327
                $raw  = $value;
328
                $cast = (int) $value;
329
330
                return '0' == $value[0] ? \octdec($value) : (((string) $raw === (string) $cast) ? $cast : $raw);
331
            case isset($value[1]) && '-' === $value[0] && \ctype_digit(\substr($value, 1)):
332
                $raw  = $value;
333
                $cast = (int) $value;
334
335
                return '0' == $value[1] ? \octdec($value) : (((string) $raw === (string) $cast) ? $cast : $raw);
336
            case 'true' === $lowercaseValue:
337
                return true;
338
            case 'false' === $lowercaseValue:
339
                return false;
340
            case isset($value[1]) && '0b' == $value[0] . $value[1] && \preg_match('/^0b[01]*$/', $value):
341
                return \bindec($value);
342
            case \is_numeric($value):
343
                return '0x' === $value[0] . $value[1] ? \hexdec($value) : (float) $value;
344
            case \preg_match('/^0x[0-9a-f]++$/i', $value):
345
                return \hexdec($value);
346
            case \preg_match('/^[+-]?[0-9]+(\.[0-9]+)?$/', $value):
347
                return (float) $value;
348
            default:
349
                return $value;
350
        }
351
    }
352
353
    /**
354
     * Converts an PHP type to a Xml value.
355
     *
356
     * @param mixed $value
357
     *
358
     * @return string
359
     */
360
    protected function xmlize($value): string
361
    {
362
        switch (true) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing preg_match('/^0x[0-9a-f]++$/i', (string)$value) of type integer to the boolean true. If you are specifically checking for non-zero, consider using something more explicit like > 0 or !== 0 instead.
Loading history...
Bug Best Practice introduced by
It seems like you are loosely comparing preg_match('/^[+-]?[0-9]...+)?$/', (string)$value) of type integer to the boolean true. If you are specifically checking for non-zero, consider using something more explicit like > 0 or !== 0 instead.
Loading history...
363
            case null === $value:
364
                return 'null';
365
            case isset($value[1]) && '-' === $value[0] && \ctype_digit(\substr($value, 1)):
366
                $raw  = $value;
367
                $cast = (int) $value;
368
369
                return (string) 0 == $value[1] ? \octdec($value) : (((string) $raw === (string) $cast) ? $cast : $raw);
370
            case true === $value:
371
                return 'true';
372
            case false === $value:
373
                return 'false';
374
            case isset($value[1]) && '0b' == $value[0] . $value[1] && \preg_match('/^0b[01]*$/', (string) $value):
375
                return (string) \bindec($value);
376
            case \preg_match('/^0x[0-9a-f]++$/i', (string) $value):
377
                return \hexdec($value);
378
            case \preg_match('/^[+-]?[0-9]+(\.[0-9]+)?$/', (string) $value):
379
                return (string) (float) $value;
380
            default:
381
                return (string) $value;
382
        }
383
    }
384
385
    protected function resolveClassObject($value)
386
    {
387
        if (\is_object($value) && !$value instanceof Closure) {
388
            $properties = [];
389
390
            foreach ((new ReflectionObject($value))->getProperties() as $property) {
391
                $property->setAccessible(true);
392
                $properties[$property->getName()] = $property->getValue($value);
393
            }
394
395
            return $properties;
396
        }
397
398
        if ($value instanceof stdClass) {
399
            return (array) $value;
400
        }
401
402
        return $value;
403
    }
404
405
    protected function getXmlErrors(bool $internalErrors)
406
    {
407
        $errors = [];
408
409
        foreach (\libxml_get_errors() as $error) {
410
            $errors[] = \sprintf(
411
                '[%s %s] %s (in %s - line %d, column %d)',
412
                \LIBXML_ERR_WARNING == $error->level ? 'WARNING' : 'ERROR',
413
                $error->code,
414
                \trim($error->message),
415
                $error->file ?: 'n/a',
416
                $error->line,
417
                $error->column
418
            );
419
        }
420
421
        \libxml_clear_errors();
422
        \libxml_use_internal_errors($internalErrors);
423
424
        return $errors;
425
    }
426
}
427