Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
173 / 173
100.00% covered (success)
100.00%
24 / 24
CRAP
100.00% covered (success)
100.00%
1 / 1
Config
100.00% covered (success)
100.00%
173 / 173
100.00% covered (success)
100.00%
24 / 24
96
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createFromData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseData
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
15
 normalizeYamlScalars
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 merge
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 mergeRecursivePreserve
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 mergeFromData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
9
 writeToFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 toArrayObject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 toJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toYaml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toIni
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toXml
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 changesAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 arrayToXml
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 arrayToYaml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 arrayToIni
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 __set
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 __unset
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 walkDotSegments
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 __get
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 __isset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resolveDotPath
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Config;
16
17use Pop\Utils\ArrayObject;
18use SimpleXMLElement;
19use DOMDocument;
20use Symfony\Component\Yaml\Yaml;
21use Symfony\Component\Yaml\Exception\ParseException as SymfonyYamlParseException;
22
23/**
24 * Config class
25 *
26 * @category   Pop
27 * @package    Pop\Config
28 * @author     Nick Sagona, III <dev@noladev.com>
29 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    5.0.0
32 */
33class Config extends ArrayObject
34{
35
36    /**
37     * Flag for whether changes are allowed after object instantiation
38     * @var bool
39     */
40    protected bool $allowChanges = false;
41
42    /**
43     * Constructor
44     *
45     * Instantiate a config object
46     *
47     * @param mixed $data
48     * @param bool  $changes
49     * @throws Exception
50     */
51    public function __construct(mixed $data = [], bool $changes = false)
52    {
53        $this->allowChanges = $changes;
54        parent::__construct($data);
55    }
56
57    /**
58     * Method to create a config object from parsed data
59     *
60     * @param  mixed $data
61     * @param  bool  $changes
62     * @return self
63     */
64    public static function createFromData(mixed $data = [], bool $changes = false): Config
65    {
66        return new self(self::parseData($data), $changes);
67    }
68
69    /**
70     * Method to parse data and return config values
71     *
72     * @param  mixed $data
73     * @throws Exception
74     * @return array
75     */
76    public static function parseData(mixed $data): array
77    {
78        if (is_array($data)) {
79            return $data;
80        }
81
82        if (!is_string($data)) {
83            throw new ParseException('Error: The config data must be a file path string or an array.');
84        }
85
86        if (!is_file($data)) {
87            throw new ParseException("Error: The config file '" . $data . "' does not exist.");
88        }
89
90        switch (strtolower(pathinfo($data, PATHINFO_EXTENSION))) {
91            // If PHP
92            case 'php':
93            case 'phtml':
94                $result = include $data;
95                break;
96            // If JSON
97            case 'json':
98                $result = json_decode(file_get_contents($data), true);
99                break;
100            // If YAML
101            case 'yaml':
102            case 'yml':
103                try {
104                    $result = Yaml::parseFile($data);
105                    if (is_array($result)) {
106                        $result = self::normalizeYamlScalars($result);
107                    }
108                } catch (SymfonyYamlParseException $e) {
109                    throw new ParseException("Error: Unable to parse the config data from '" . $data . "'.", 0, $e);
110                }
111                break;
112            // If INI
113            case 'ini':
114                $result = @parse_ini_file($data, true);
115                break;
116            // If XML
117            case 'xml':
118                $result = (array)simplexml_load_file($data);
119                break;
120            default:
121                throw new UnsupportedFormatException(
122                    "Error: Unable to determine the config format from the file '" . $data . "'. " .
123                    "Supported extensions are .php, .phtml, .json, .yaml, .yml, .ini and .xml."
124                );
125        }
126
127        if (!is_array($result)) {
128            throw new ParseException("Error: Unable to parse the config data from '" . $data . "'.");
129        }
130
131        return $result;
132    }
133
134    /**
135     * Normalize YAML scalars parsed by symfony/yaml back to the legacy behavior
136     * of the PECL yaml extension (libyaml, YAML 1.1), which converted additional
137     * boolean words (yes/no/on/off, etc.) and leading-zero octal-looking integers
138     * to booleans and integers, respectively. symfony/yaml (YAML 1.2-ish core
139     * schema) leaves those as plain strings. This is applied only to the YAML
140     * read path, not to writing.
141     *
142     * Note: ISO date-like scalars are a known, intentionally unfixed gap -
143     * symfony/yaml auto-converts them to a Unix timestamp int with no public
144     * API to prevent it.
145     *
146     * @param  mixed $value
147     * @return mixed
148     */
149    protected static function normalizeYamlScalars(mixed $value): mixed
150    {
151        if (is_array($value)) {
152            foreach ($value as $key => $v) {
153                $value[$key] = self::normalizeYamlScalars($v);
154            }
155            return $value;
156        }
157
158        if (is_string($value)) {
159            if (preg_match('/^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|on|On|ON|off|Off|OFF)$/', $value)) {
160                return in_array($value, ['y', 'Y', 'yes', 'Yes', 'YES', 'on', 'On', 'ON'], true);
161            }
162            if (preg_match('/^0[0-7]+$/', $value)) {
163                return octdec($value);
164            }
165        }
166
167        return $value;
168    }
169
170    /**
171     * Merge the values of another config object into this one.
172     * By default, existing values are overwritten, unless the
173     * $preserve flag is set to true.
174     *
175     * @param  mixed $data
176     * @param  bool  $preserve
177     * @throws Exception
178     * @return Config
179     */
180    public function merge(mixed $data, bool $preserve = false): Config
181    {
182        if (!$this->allowChanges) {
183            throw new ChangesNotAllowedException('Real-time configuration changes are not allowed.');
184        }
185
186        if ($data instanceof Config) {
187            $data = $data->toArray();
188        }
189
190        $this->data = ($preserve) ?
191            $this->mergeRecursivePreserve($this->data, $data) : array_replace_recursive($this->data, $data);
192
193        return $this;
194    }
195
196    /**
197     * Recursively merge $new into $original, keeping $original's value whenever
198     * a key collides and at least one side isn't an array.
199     *
200     * @param  array $original
201     * @param  array $new
202     * @return array
203     */
204    private function mergeRecursivePreserve(array $original, array $new): array
205    {
206        foreach ($new as $key => $value) {
207            if (!array_key_exists($key, $original)) {
208                $original[$key] = $value;
209            } else if (is_array($original[$key]) && is_array($value) &&
210                !(array_is_list($original[$key]) && array_is_list($value))) {
211                $original[$key] = $this->mergeRecursivePreserve($original[$key], $value);
212            }
213        }
214
215        return $original;
216    }
217
218    /**
219     * Merge the values of another config object into this one.
220     * By default, existing values are overwritten, unless the
221     * $preserve flag is set to true.
222     *
223     * @param  mixed $data
224     * @param  bool  $preserve
225     * @throws Exception
226     * @return Config
227     */
228    public function mergeFromData(mixed $data, bool $preserve = false): Config
229    {
230        if (!$this->allowChanges) {
231            throw new ChangesNotAllowedException('Real-time configuration changes are not allowed.');
232        }
233
234        return $this->merge(self::parseData($data), $preserve);
235    }
236
237    /**
238     * Render data to a string format
239     *
240     * @param  string $format
241     * @throws Exception
242     * @return string
243     */
244    public function render(string $format): string
245    {
246        $config = '';
247
248        switch (strtolower($format)) {
249            case 'php':
250            case 'phtml':
251                $config  = '<?php' . PHP_EOL . PHP_EOL;
252                $config .= 'return ' . var_export($this->toArray(), true) . ';';
253                $config .= PHP_EOL;
254                break;
255            case 'json':
256                $config = $this->toJson();
257                break;
258            case 'yml':
259            case 'yaml':
260                return $this->toYaml();
261            case 'ini':
262                $config = $this->toIni();
263                break;
264            case 'xml':
265                $config =$this->toXml();
266                break;
267            default:
268                throw new UnsupportedFormatException(
269                    "Invalid type '" . $format . "'. Supported config file types are PHP, JSON, YAML, INI or XML."
270                );
271        }
272
273        return $config;
274    }
275
276    /**
277     * Write the config data to file
278     *
279     * @param  string $filename
280     * @throws Exception
281     * @return void
282     */
283    public function writeToFile(string $filename): void
284    {
285        if (str_contains($filename, '.')) {
286            $ext = strtolower(substr($filename, (strrpos($filename, '.') + 1)));
287            file_put_contents($filename, $this->render($ext));
288        }
289    }
290
291    /**
292     * Get the config values as an array
293     *
294     * @throws \Pop\Utils\Exception
295     * @return ArrayObject|\ArrayObject
296     */
297    public function toArrayObject($native = false): ArrayObject|\ArrayObject
298    {
299        return ($native) ? new \ArrayObject($this->toArray(), \ArrayObject::ARRAY_AS_PROPS) : new ArrayObject($this->toArray());
300    }
301
302    /**
303     * Get the config values as a JSON string
304     *
305     * @return string
306     */
307    public function toJson(): string
308    {
309        return $this->jsonSerialize(JSON_PRETTY_PRINT);
310    }
311
312    /**
313     * Get the config values as an YAML string
314     *
315     * @return string
316     */
317    public function toYaml(): string
318    {
319        return $this->arrayToYaml($this->toArray());
320    }
321
322    /**
323     * Get the config values as an INI string
324     *
325     * @return string
326     */
327    public function toIni(): string
328    {
329        return $this->arrayToIni($this->toArray());
330    }
331
332    /**
333     * Get the config values as an XML string
334     *
335     * @return string
336     */
337    public function toXml(): string
338    {
339        $config = new SimpleXMLElement('<?xml version="1.0"?><config></config>');
340        $this->arrayToXml($this->toArray(), $config);
341
342        $dom = new DOMDocument('1.0');
343        $dom->preserveWhiteSpace = false;
344        $dom->formatOutput       = true;
345        $dom->loadXML($config->asXML());
346        return $dom->saveXML();
347    }
348
349    /**
350     * Return if changes to the config are allowed.
351     *
352     * @return bool
353     */
354    public function changesAllowed(): bool
355    {
356        return $this->allowChanges;
357    }
358
359    /**
360     * Method to convert array to XML
361     *
362     * @param  array            $array
363     * @param  SimpleXMLElement $config
364     * @return void
365     */
366    protected function arrayToXml(array $array, SimpleXMLElement &$config): void
367    {
368        foreach($array as $key => $value) {
369            if(is_array($value)) {
370                $subNode = (!is_numeric($key)) ? $config->addChild($key) : $config->addChild('item');
371                $this->arrayToXml($value, $subNode);
372            } else {
373                if (!is_numeric($key)) {
374                    $config->addChild($key, htmlspecialchars((string)$value));
375                } else {
376                    $config->addChild('item', htmlspecialchars((string)$value));
377                }
378            }
379        }
380    }
381
382    /**
383     * Method to convert array to Yaml
384     *
385     * @param  array $array
386     * @return string
387     */
388    protected function arrayToYaml(array $array): string
389    {
390        return Yaml::dump($array, 512);
391    }
392
393    /**
394     * Method to convert array to INI
395     *
396     * @param  array $array
397     * @return string
398     */
399    protected function arrayToIni(array $array): string
400    {
401        $ini          = '';
402        $lastWasArray = false;
403
404        foreach ($array as $key => $value) {
405            if (is_array($value)) {
406                if (!$lastWasArray) {
407                    $ini .= PHP_EOL;
408                }
409                $ini .= '[' . $key . ']' . PHP_EOL;
410                foreach ($value as $k => $v) {
411                    if (!is_array($v)) {
412                        $ini .= $key .
413                            '[' . ((!is_numeric($k)) ? $k : null) . '] = ' .
414                            ((!is_numeric($v)) ? '"' . $v . '"' : $v) . PHP_EOL;
415                    }
416                }
417                $ini .= PHP_EOL;
418                $lastWasArray = true;
419            } else {
420                $ini .= $key . " = " . ((!is_numeric($value)) ? '"' . $value . '"' : $value) . PHP_EOL;
421                $lastWasArray = false;
422            }
423        }
424
425        return $ini;
426    }
427
428    /**
429     * Set a value
430     *
431     * @param  ?string $name
432     * @param  mixed $value
433     * @return void
434     */
435    public function __set(?string $name = null, mixed $value = null): void
436    {
437        if (!$this->allowChanges) {
438            throw new ChangesNotAllowedException('Real-time configuration changes are not allowed.');
439        }
440
441        if ($name === null || !str_contains($name, '.') || array_key_exists($name, (array)$this->data)) {
442            parent::__set($name, $value);
443            return;
444        }
445
446        $segments    = explode('.', $name);
447        $lastSegment = array_pop($segments);
448
449        $data =& $this->walkDotSegments($segments, true);
450        $data[$lastSegment] = $value;
451    }
452
453    /**
454     * Unset a value
455     *
456     * @param  string $name
457     * @throws Exception
458     * @return void
459     */
460    public function __unset(string $name): void
461    {
462        if (!$this->allowChanges) {
463            throw new ChangesNotAllowedException('Real-time configuration changes are not allowed.');
464        }
465
466        if (!str_contains($name, '.') || array_key_exists($name, (array)$this->data)) {
467            parent::__unset($name);
468            return;
469        }
470
471        $segments    = explode('.', $name);
472        $lastSegment = array_pop($segments);
473
474        $data =& $this->walkDotSegments($segments, false);
475        if (is_array($data) && array_key_exists($lastSegment, $data)) {
476            unset($data[$lastSegment]);
477        }
478    }
479
480    /**
481     * Walk $this->data by reference along a set of dot-notation path segments,
482     * returning a reference to the container that should hold the final segment.
483     * When $autoVivify is true, missing/non-array segments are created as empty
484     * arrays along the way; when false, the walk stops and returns null as soon
485     * as a segment is missing.
486     *
487     * @param  array $segments
488     * @param  bool  $autoVivify
489     * @return mixed
490     */
491    private function &walkDotSegments(array $segments, bool $autoVivify): mixed
492    {
493        $data =& $this->data;
494
495        foreach ($segments as $segment) {
496            if ($autoVivify) {
497                if (!isset($data[$segment]) || !is_array($data[$segment])) {
498                    $data[$segment] = [];
499                }
500            } else if (!is_array($data) || !array_key_exists($segment, $data)) {
501                $missing = null;
502                return $missing;
503            }
504            $data =& $data[$segment];
505        }
506
507        return $data;
508    }
509
510    /**
511     * Get a value, supporting dot notation for nested keys
512     *
513     * @param  string $name
514     * @return mixed
515     */
516    public function __get(string $name): mixed
517    {
518        [$found, $value] = $this->resolveDotPath($name);
519        return $found ? $value : null;
520    }
521
522    /**
523     * Determine if a value is set, supporting dot notation for nested keys
524     *
525     * @param  string $name
526     * @return bool
527     */
528    public function __isset(string $name): bool
529    {
530        [$found] = $this->resolveDotPath($name);
531        return $found;
532    }
533
534    /**
535     * Resolve a plain or dot-notation key against $this->data.
536     *
537     * @param  string $name
538     * @return array{0: bool, 1: mixed}
539     */
540    private function resolveDotPath(string $name): array
541    {
542        $data = (array)$this->data;
543
544        if (array_key_exists($name, $data)) {
545            return [true, $data[$name]];
546        }
547
548        if (!str_contains($name, '.')) {
549            return [false, null];
550        }
551
552        $value = $data;
553        foreach (explode('.', $name) as $segment) {
554            if (!is_array($value) || !array_key_exists($segment, $value)) {
555                return [false, null];
556            }
557            $value = $value[$segment];
558        }
559
560        return [true, $value];
561    }
562
563}